tina4ruby 3.13.36 → 3.13.38

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.
@@ -8,6 +8,18 @@ module Tina4
8
8
  include SchemaSplit
9
9
  attr_reader :connection
10
10
 
11
+ # Process-wide write lock — parity with Python's SQLiteAdapter._write_lock.
12
+ #
13
+ # DB-contract B (v3.13.37): get_next_id's atomic sequence-table increment
14
+ # serialises the ENTIRE ensure-table + seed + increment op under this lock
15
+ # so concurrent callers can never read the same counter and return a
16
+ # duplicate id. Class-level (one per process) so every Database instance /
17
+ # pooled connection contends on the same lock for the same SQLite file.
18
+ @write_lock = Mutex.new
19
+ class << self
20
+ attr_reader :write_lock
21
+ end
22
+
11
23
  def connect(connection_string, username: nil, password: nil)
12
24
  require "sqlite3"
13
25
  db_path = self.class.resolve_path(connection_string)
@@ -87,12 +99,23 @@ module Tina4
87
99
  @connection.execute("BEGIN TRANSACTION")
88
100
  end
89
101
 
102
+ # Committing/rolling back when no transaction is open is a harmless no-op,
103
+ # NOT a failure — SQLite raises "cannot commit - no transaction is active"
104
+ # in that case. Swallow ONLY that specific condition so a stray commit
105
+ # (e.g. after an autocommit standalone write) doesn't poison the
106
+ # Database-level @last_error. A genuine commit/rollback failure (disk I/O,
107
+ # constraint deferral, locked DB) still propagates so Database#commit can
108
+ # FAIL LOUD per the DB-contract.
90
109
  def commit
91
110
  @connection.execute("COMMIT")
111
+ rescue SQLite3::SQLException => e
112
+ raise unless e.message.to_s.downcase.include?("no transaction is active")
92
113
  end
93
114
 
94
115
  def rollback
95
116
  @connection.execute("ROLLBACK")
117
+ rescue SQLite3::SQLException => e
118
+ raise unless e.message.to_s.downcase.include?("no transaction is active")
96
119
  end
97
120
 
98
121
  # v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
data/lib/tina4/env.rb CHANGED
@@ -74,13 +74,17 @@ module Tina4
74
74
  end
75
75
 
76
76
  module Env
77
+ # NOTE: TINA4_SECRET is deliberately ABSENT here. The default signing
78
+ # secret must never become a guessable built-in. A blank secret is the
79
+ # signal for Auth.ensure_dev_secret to mint a per-machine random dev secret
80
+ # (saved to gitignored .env.local) in dev, or to emit the actionable
81
+ # "set TINA4_SECRET" warning in CI/prod. Parity with the Python master.
77
82
  DEFAULT_ENV = {
78
83
  "PROJECT_NAME" => "Tina4 Ruby Project",
79
84
  "TINA4_SWAGGER_VERSION" => "1.0.0",
80
85
  "TINA4_LOCALE" => "en",
81
86
  "TINA4_DEBUG" => "true",
82
- "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
83
- "TINA4_SECRET" => "tina4-secret-change-me"
87
+ "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]"
84
88
  }.freeze
85
89
 
86
90
  # Typed env-var coercion — parity with tina4_python's Env class,
@@ -157,9 +161,27 @@ module Tina4
157
161
  unless File.exist?(env_file)
158
162
  create_default_env(env_file)
159
163
  end
164
+ # Precedence: real-env > .env.local > .env. Both loads are first-wins
165
+ # (override=false / `ENV[key] ||= value`), so a key already present in
166
+ # the real process environment is NEVER clobbered. .env.local loads
167
+ # FIRST so its values beat .env, but a real env var set before boot
168
+ # still wins over both. This is the security-correct ordering: a stray
169
+ # gitignored .env.local (e.g. a stale auto-generated dev secret) must
170
+ # not override an explicitly-set real TINA4_SECRET. The ensure-dev-secret
171
+ # bootstrap runs AFTER this (only mints a secret if still unset in dev).
172
+ load_local_env(root_dir)
160
173
  parse_env_file(env_file)
161
174
  end
162
175
 
176
+ # Load .env.local with first-wins semantics (override=false). A real
177
+ # process env var already present wins; this only fills keys not already
178
+ # set. Loaded BEFORE .env so .env.local beats .env (real-env > .env.local
179
+ # > .env). No-op when the file is absent (common for fresh checkouts).
180
+ def load_local_env(root_dir = Dir.pwd)
181
+ local_file = File.join(root_dir, ".env.local")
182
+ parse_env_file(local_file) if File.exist?(local_file)
183
+ end
184
+
163
185
  # Get an env var value, with optional default
164
186
  def get_env(key, default = nil)
165
187
  ENV[key.to_s] || default
@@ -213,7 +235,17 @@ module Tina4
213
235
  File.write(path, content)
214
236
  end
215
237
 
216
- def parse_env_file(path)
238
+ # Parse a dotenv file into ENV.
239
+ #
240
+ # override=false (default): `ENV[key] ||= value` — first-wins; a key
241
+ # already present (real env var, or a higher-precedence file loaded
242
+ # earlier) is never clobbered. Both .env.local and .env are loaded this
243
+ # way; ordering (.env.local first) gives the precedence real-env >
244
+ # .env.local > .env.
245
+ # override=true: `ENV[key] = value` — unconditional set. NOT used by the
246
+ # boot load sequence; kept only as an explicit opt-in for callers that
247
+ # genuinely need to force values.
248
+ def parse_env_file(path, override: false)
217
249
  return unless File.exist?(path)
218
250
  File.readlines(path).each do |line|
219
251
  line = line.strip
@@ -221,7 +253,11 @@ module Tina4
221
253
  if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=["']?(.*)["']?\z/))
222
254
  key = match[1]
223
255
  value = match[2].gsub(/["']\z/, "")
224
- ENV[key] ||= value
256
+ if override
257
+ ENV[key] = value
258
+ else
259
+ ENV[key] ||= value
260
+ end
225
261
  @loaded_keys ||= []
226
262
  @loaded_keys << key
227
263
  end
data/lib/tina4/events.rb CHANGED
@@ -60,13 +60,28 @@ module Tina4
60
60
  #
61
61
  # results = Tina4::Events.emit("user.created", user_data)
62
62
  #
63
- def emit(event, *args)
63
+ # Listener isolation (visible-but-resilient): each listener is called
64
+ # inside a rescue so ONE throwing listener never aborts the rest of the
65
+ # emit. A failed listener is LOGGED (never silent) and contributes a nil
66
+ # slot, so N listeners always yield N results in priority order. Pass
67
+ # strict: true to RE-RAISE on the first listener error instead of
68
+ # isolating (later listeners then do NOT run).
69
+ def emit(event, *args, strict: false)
64
70
  entries = @listeners[event].dup
65
71
  results = []
66
72
  entries.each do |entry|
67
- # Remove one-time listeners before calling so re-entrant emits are safe
73
+ # Remove one-time listeners before calling so re-entrant emits are
74
+ # safe AND so the one-shot is gone before any throw — cleanup stays
75
+ # correct under isolation.
68
76
  @listeners[event].delete(entry) if entry[:once]
69
- results << entry[:callback].call(*args)
77
+ begin
78
+ results << entry[:callback].call(*args)
79
+ rescue StandardError, ScriptError => error
80
+ raise if strict
81
+
82
+ log_listener_error(event, error)
83
+ results << nil
84
+ end
70
85
  end
71
86
  results
72
87
  end
@@ -82,19 +97,29 @@ module Tina4
82
97
  end
83
98
 
84
99
  # Fire an event asynchronously. Each listener runs in its own thread.
85
- # Errors in listeners are silently caught.
86
100
  #
87
101
  # Tina4::Events.emit_async("user.created", user_data)
88
102
  #
89
- def emit_async(event, *args)
103
+ # Listener isolation: each threaded listener is wrapped so one rejection
104
+ # never aborts the others. A failed listener is LOGGED (never silent).
105
+ # Pass strict: true to RE-RAISE the first listener error on the main
106
+ # thread (the thread that raised aborts on join) instead of isolating.
107
+ def emit_async(event, *args, strict: false)
90
108
  return unless @listeners&.key?(event)
91
109
 
92
- @listeners[event].sort_by { |l| -(l[:priority] || 0) }.each do |listener|
110
+ @listeners[event].sort_by { |l| -(l[:priority] || 0) }.map do |listener|
93
111
  Thread.new do
112
+ # In strict mode the error is re-raised to surface on #join; mute
113
+ # the per-thread auto-report so the deliberate raise doesn't spam
114
+ # stderr (the caller still sees it via join).
115
+ Thread.current.report_on_exception = false if strict
94
116
  begin
95
117
  listener[:callback].call(*args)
96
- rescue => e
97
- # Async emit silently catches errors
118
+ rescue StandardError, ScriptError => error
119
+ raise if strict
120
+
121
+ log_listener_error(event, error)
122
+ nil
98
123
  end
99
124
  end
100
125
  end
@@ -104,6 +129,27 @@ module Tina4
104
129
  def clear
105
130
  @listeners.clear
106
131
  end
132
+
133
+ private
134
+
135
+ # NEVER silently swallow a listener error — always surface it. Logs via
136
+ # Tina4::Log.warning with BOTH the event name and the error class+message.
137
+ # Wrapped so a broken logger can't break the bus: on any logger failure it
138
+ # falls back to $stderr so the error is still seen.
139
+ def log_listener_error(event, error)
140
+ Tina4::Log.warning(
141
+ "Event listener for '#{event}' raised #{error.class.name}: #{error.message}"
142
+ )
143
+ rescue StandardError
144
+ begin
145
+ $stderr.puts(
146
+ "Event listener for '#{event}' raised #{error.class.name}: #{error.message}"
147
+ )
148
+ $stderr.flush
149
+ rescue StandardError
150
+ # Last-resort: never let logging break the event bus.
151
+ end
152
+ end
107
153
  end
108
154
  end
109
155
  end
data/lib/tina4/graphql.rb CHANGED
@@ -463,12 +463,33 @@ module Tina4
463
463
  arguments = parse_arguments
464
464
  end
465
465
 
466
+ # Directives (@skip, @include, @auth, @role, @guest, ...) appear after the
467
+ # field's arguments and before its selection set. Without parsing them the
468
+ # executor's check_directives never fires — @skip/@include are silently
469
+ # ignored. Each directive is { name:, arguments: } to match check_directives.
470
+ directives = parse_directives
471
+
466
472
  selection_set = nil
467
473
  if current&.value == "{"
468
474
  selection_set = parse_selection_set
469
475
  end
470
476
 
471
- { kind: :field, name: field_name, alias: alias_name, arguments: arguments, selection_set: selection_set }
477
+ { kind: :field, name: field_name, alias: alias_name, arguments: arguments, directives: directives, selection_set: selection_set }
478
+ end
479
+
480
+ # Parse a run of leading @directive(args) tokens into an array of
481
+ # { name:, arguments: } hashes (the shape GraphQLExecutor#check_directives
482
+ # consumes). Returns [] when no directive is present.
483
+ def parse_directives
484
+ directives = []
485
+ while current && current.type == :punct && current.value == "@"
486
+ advance # consume '@'
487
+ name = expect(:name).value
488
+ args = {}
489
+ args = parse_arguments if current&.value == "("
490
+ directives << { name: name, arguments: args }
491
+ end
492
+ directives
472
493
  end
473
494
 
474
495
  def parse_arguments
@@ -575,8 +596,13 @@ module Tina4
575
596
  # ─── Executor ─────────────────────────────────────────────────────────
576
597
 
577
598
  class GraphQLExecutor
578
- def initialize(schema)
599
+ # max_depth bounds selection-set nesting (DoS / stack-overflow guard).
600
+ # Threaded down from the owning GraphQL instance; <= 0 disables the guard.
601
+ attr_accessor :max_depth
602
+
603
+ def initialize(schema, max_depth: 50)
579
604
  @schema = schema
605
+ @max_depth = max_depth
580
606
  end
581
607
 
582
608
  def execute(document, variables: {}, context: {}, operation_name: nil)
@@ -618,8 +644,12 @@ module Tina4
618
644
  data = {}
619
645
  errors = []
620
646
 
647
+ # Top-level selections start at depth 1. Depth is incremented on every
648
+ # recursive entry (sub-selections, fragment spreads, inline fragments) so
649
+ # an over-deep query OR a circular fragment is caught before the
650
+ # interpreter stack overflows.
621
651
  operation[:selection_set].each do |selection|
622
- resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors)
652
+ resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors, 1)
623
653
  end
624
654
 
625
655
  result = { "data" => data }
@@ -629,25 +659,31 @@ module Tina4
629
659
 
630
660
  private
631
661
 
632
- def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors)
662
+ def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors, depth = 1)
663
+ # Depth guard — append a structured error and stop recursing this branch.
664
+ if @max_depth && @max_depth > 0 && depth > @max_depth
665
+ errors << { "message" => "Query exceeds maximum depth of #{@max_depth}" }
666
+ return
667
+ end
668
+
633
669
  case selection[:kind]
634
670
  when :field
635
- resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
671
+ resolve_field(selection, fields, parent, variables, context, fragments, data, errors, depth)
636
672
  when :fragment_spread
637
673
  frag = fragments[selection[:name]]
638
674
  if frag
639
675
  frag[:selection_set].each do |sel|
640
- resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
676
+ resolve_selection(sel, fields, parent, variables, context, fragments, data, errors, depth + 1)
641
677
  end
642
678
  end
643
679
  when :inline_fragment
644
680
  selection[:selection_set].each do |sel|
645
- resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
681
+ resolve_selection(sel, fields, parent, variables, context, fragments, data, errors, depth + 1)
646
682
  end
647
683
  end
648
684
  end
649
685
 
650
- def resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
686
+ def resolve_field(selection, fields, parent, variables, context, fragments, data, errors, depth = 1)
651
687
  field_name = selection[:name]
652
688
  output_name = selection[:alias] || field_name
653
689
 
@@ -687,7 +723,7 @@ module Tina4
687
723
  nested = {}
688
724
  sub_fields = item.is_a?(Hash) ? item_fields(item) : {}
689
725
  selection[:selection_set].each do |sel|
690
- resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors)
726
+ resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors, depth + 1)
691
727
  end
692
728
  nested
693
729
  end
@@ -695,7 +731,7 @@ module Tina4
695
731
  nested = {}
696
732
  sub_fields = item_fields(value)
697
733
  selection[:selection_set].each do |sel|
698
- resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors)
734
+ resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors, depth + 1)
699
735
  end
700
736
  data[output_name] = nested
701
737
  else
@@ -705,7 +741,12 @@ module Tina4
705
741
  data[output_name] = coerce_value(value)
706
742
  end
707
743
  rescue => e
708
- errors << { "message" => e.message, "path" => [output_name] }
744
+ # Log the real cause; only surface the detail to the client in debug
745
+ # mode — a resolver exception can carry internal state (DB errors,
746
+ # credentials) that must not leak. Mirrors the Python master.
747
+ Tina4::Log.error("GraphQL resolver '#{field_name}' failed: #{e.message}")
748
+ detail = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) ? e.message : "Internal server error"
749
+ errors << { "message" => detail, "path" => [output_name] }
709
750
  data[output_name] = nil
710
751
  end
711
752
  end
@@ -845,6 +886,16 @@ module Tina4
845
886
  class GraphQL
846
887
  attr_reader :schema
847
888
 
889
+ # Maximum selection-set nesting depth (DoS / stack-overflow guard).
890
+ # Read from TINA4_GRAPHQL_MAX_DEPTH (default 50; <= 0 disables). Exposed so
891
+ # tests/app code can override it; the writer keeps the executor in sync.
892
+ attr_reader :max_depth
893
+
894
+ def max_depth=(value)
895
+ @max_depth = value
896
+ @executor&.max_depth = value
897
+ end
898
+
848
899
  # Class-level toggle for ORM auto-schema generation. Defaults to true,
849
900
  # can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
850
901
  # user app code can branch on this before calling `schema.from_orm(...)`.
@@ -912,7 +963,12 @@ module Tina4
912
963
 
913
964
  def initialize(schema = nil)
914
965
  @schema = schema || GraphQLSchema.new
915
- @executor = GraphQLExecutor.new(@schema)
966
+ # Maximum selection-set nesting depth. A deeply nested query (or a
967
+ # circular fragment) would otherwise recurse without bound — a classic
968
+ # GraphQL DoS / stack-overflow vector. Default 50 is far beyond any
969
+ # legitimate query; TINA4_GRAPHQL_MAX_DEPTH <= 0 disables the guard.
970
+ @max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
971
+ @executor = GraphQLExecutor.new(@schema, max_depth: @max_depth)
916
972
  @field_resolvers = {}
917
973
 
918
974
  # Drain any resolvers registered via the class-level GraphQL.resolve()
@@ -1,6 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
+ # Marker for trusted, pre-sanitised HTML that must render UNESCAPED.
5
+ #
6
+ # String/scalar children of an HtmlElement are HTML-escaped by default to
7
+ # prevent stored/reflected XSS. Wrap a value in Raw to opt out of escaping
8
+ # when (and only when) you have already sanitised it yourself.
9
+ #
10
+ # Tina4::HtmlElement.new("div").call("<b>x</b>") # &lt;b&gt;x&lt;/b&gt; (escaped)
11
+ # Tina4::HtmlElement.new("div").call(Tina4::Raw.new("<b>x</b>")) # <b>x</b> (raw)
12
+ #
13
+ # Raw is the primary name; SafeString is the alias (and is the same class
14
+ # Frond already uses to mark trusted output, so a SafeString returned by a
15
+ # Frond filter also renders raw as an HtmlElement child). Defined defensively
16
+ # in case this file is ever loaded before Frond.
17
+ SafeString = Class.new(String) unless defined?(Tina4::SafeString)
18
+
19
+ # Primary name for the trusted-markup wrapper — an alias of SafeString.
20
+ Raw = SafeString
21
+
22
+ # Convenience constructor so callers can write Tina4::Raw("<b>x</b>")
23
+ # in addition to Tina4::Raw.new("<b>x</b>").
24
+ def self.Raw(value)
25
+ Raw.new(value.to_s)
26
+ end
27
+
4
28
  # Programmatic HTML builder — avoids string concatenation.
5
29
  #
6
30
  # Usage:
@@ -14,6 +38,9 @@ module Tina4
14
38
  # include Tina4::HtmlHelpers
15
39
  # html = _div({ class: "card" }, _p("Hello"))
16
40
  #
41
+ # String/scalar children are HTML-escaped by default to defeat XSS; nested
42
+ # HtmlElement children render themselves (already escaped, no double-escape);
43
+ # a Raw / SafeString child renders verbatim (explicit opt-in for trusted markup).
17
44
  class HtmlElement
18
45
  VOID_TAGS = %w[
19
46
  area base br col embed hr img input
@@ -90,12 +117,7 @@ module Tina4
90
117
  when false, nil
91
118
  next
92
119
  else
93
- escaped = value.to_s
94
- .gsub("&", "&amp;")
95
- .gsub('"', "&quot;")
96
- .gsub("<", "&lt;")
97
- .gsub(">", "&gt;")
98
- html << " #{key}=\"#{escaped}\""
120
+ html << " #{key}=\"#{escape_text(value.to_s)}\""
99
121
  end
100
122
  end
101
123
 
@@ -107,12 +129,38 @@ module Tina4
107
129
  html << ">"
108
130
 
109
131
  @children.each do |child|
110
- html << child.to_s
132
+ case child
133
+ when HtmlElement
134
+ # Nested elements render themselves (they already escape their own
135
+ # children) — emit as-is to avoid double-escaping.
136
+ html << child.to_s
137
+ when Raw
138
+ # Explicitly trusted markup — emit unescaped. Raw subclasses String,
139
+ # so this branch MUST come before the generic scalar escape below.
140
+ html << child.to_s
141
+ else
142
+ # Plain string/scalar child — escape to defeat stored/reflected XSS.
143
+ html << escape_text(child.to_s)
144
+ end
111
145
  end
112
146
 
113
147
  html << "</#{@tag}>"
114
148
  html
115
149
  end
150
+
151
+ private
152
+
153
+ # HTML-escape a string for safe inclusion in text content or a
154
+ # double-quoted attribute value. Mirrors Python's html.escape(quote=True):
155
+ # & < > " ' are all encoded.
156
+ def escape_text(value)
157
+ value
158
+ .gsub("&", "&amp;")
159
+ .gsub("<", "&lt;")
160
+ .gsub(">", "&gt;")
161
+ .gsub('"', "&quot;")
162
+ .gsub("'", "&#x27;")
163
+ end
116
164
  end
117
165
 
118
166
  # Module providing _div, _p, _span, etc. helper methods.
data/lib/tina4/mcp.rb CHANGED
@@ -651,9 +651,16 @@ module Tina4
651
651
  db = Tina4.database
652
652
  return { "error" => "No database connection" } if db.nil?
653
653
  param_list = params.is_a?(String) ? JSON.parse(params) : params
654
- result = db.execute(sql, param_list)
655
- db.commit rescue nil
656
- { "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
654
+ # db.execute() now RAISES on a SQL error (it no longer returns false).
655
+ # Catch it and return a clean { error: } payload instead of letting the
656
+ # exception escape the tool handler.
657
+ begin
658
+ result = db.execute(sql, param_list)
659
+ db.commit rescue nil
660
+ { "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
661
+ rescue => e
662
+ { "error" => db.get_error || e.message }
663
+ end
657
664
  }, "Execute arbitrary SQL (INSERT/UPDATE/DELETE/DDL)")
658
665
 
659
666
  server.register_tool("database_tables", lambda {