tina4ruby 3.13.38 → 3.13.39

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.
@@ -155,6 +155,14 @@ module Tina4
155
155
 
156
156
  # Execute the query and return the database result.
157
157
  #
158
+ # v3.13.39: with no `.limit()` set, get returns ALL matching rows. It
159
+ # previously applied a silent default `LIMIT 100` — a data-loss-on-read
160
+ # footgun where the 101st row vanished without a trace. An explicit
161
+ # `.limit(n)` is still honoured; `to_sql` never injects a default LIMIT
162
+ # either. When no limit was requested we pass `limit: nil` to db.fetch —
163
+ # its "no truncation" sentinel (fetch only appends LIMIT when `limit` is
164
+ # truthy and the SQL doesn't already carry one).
165
+ #
158
166
  # @return [Object] The result from db.fetch.
159
167
  def get
160
168
  ensure_db!
@@ -164,7 +172,7 @@ module Tina4
164
172
  @db.fetch(
165
173
  sql,
166
174
  all_params.empty? ? [] : all_params,
167
- limit: @limit_val || 100,
175
+ limit: @limit_val,
168
176
  offset: @offset_val || 0
169
177
  )
170
178
  end
@@ -322,8 +330,19 @@ module Tina4
322
330
  return [{ field => { mongo_op => val } }, param_index + 1]
323
331
  end
324
332
 
325
- # Fallback
326
- [{ "$where" => cond }, param_index]
333
+ # v3.13.39: no silent $where fallback. Previously an unparseable
334
+ # condition was wrapped as `{ "$where" => <raw condition string> }` — a
335
+ # raw-JS sink that is both injection-shaped (the WHERE string runs as
336
+ # JavaScript on the server) and silently different semantics from the
337
+ # SQL the caller wrote. Fail loud instead: name the clause so the caller
338
+ # fixes it rather than shipping a surprise $where.
339
+ raise ArgumentError,
340
+ "QueryBuilder#to_mongo: cannot translate WHERE clause to a " \
341
+ "MongoDB filter: #{cond.inspect}. Supported forms: " \
342
+ "'<field> <op> ?' (=, !=, <>, >, >=, <, <=), '<field> LIKE ?', " \
343
+ "'<field> [NOT] IN (?)', '<field> IS [NOT] NULL'. " \
344
+ "Rewrite the condition in one of those forms (to_mongo will not " \
345
+ "silently emit a raw $where JavaScript expression)."
327
346
  end
328
347
 
329
348
  # Merge multiple single-field mongo condition hashes into one.
@@ -8,9 +8,11 @@ module Tina4
8
8
  @brokers = options[:brokers] || "localhost:9092"
9
9
  @group_id = options[:group_id] || "tina4_consumer_group"
10
10
 
11
+ security = self.class._security_config
12
+
11
13
  producer_config = {
12
14
  "bootstrap.servers" => @brokers
13
- }
15
+ }.merge(security)
14
16
  @producer = Rdkafka::Config.new(producer_config).producer
15
17
 
16
18
  consumer_config = {
@@ -18,13 +20,48 @@ module Tina4
18
20
  "group.id" => @group_id,
19
21
  "auto.offset.reset" => "earliest",
20
22
  "enable.auto.commit" => "false"
21
- }
23
+ }.merge(security)
22
24
  @consumer = Rdkafka::Config.new(consumer_config).consumer
23
25
  @subscribed_topics = []
24
26
  rescue LoadError
25
27
  raise "Kafka backend requires the 'rdkafka' gem. Install with: gem install rdkafka"
26
28
  end
27
29
 
30
+ # Build SSL/SASL client config from env (for a TLS broker/proxy).
31
+ #
32
+ # Mirrors tina4_python KafkaConnector._security_config: each setting is
33
+ # read from the Tina4-namespaced env var first (TINA4_KAFKA_<NAME>) and
34
+ # falls back to the bare librdkafka-convention name (KAFKA_<NAME>) that
35
+ # many Kafka deployments already set. Honours security.protocol (e.g. SSL,
36
+ # SASL_SSL), ssl.ca.location, and optional SASL (mechanism / username /
37
+ # password). Unset keys are omitted, leaving librdkafka's PLAINTEXT default.
38
+ def self._security_config
39
+ # rdkafka key -> env suffix (read as TINA4_KAFKA_<suffix>, then KAFKA_<suffix>)
40
+ mapping = {
41
+ "security.protocol" => "SECURITY_PROTOCOL",
42
+ "ssl.ca.location" => "SSL_CA_LOCATION",
43
+ "sasl.mechanism" => "SASL_MECHANISM",
44
+ "sasl.username" => "SASL_USERNAME",
45
+ "sasl.password" => "SASL_PASSWORD"
46
+ }
47
+ config = {}
48
+ mapping.each do |rdk, suffix|
49
+ value = env_value("TINA4_KAFKA_#{suffix}") || env_value("KAFKA_#{suffix}")
50
+ config[rdk] = value if value
51
+ end
52
+ config
53
+ end
54
+
55
+ # Read an env var, treating empty/blank values as unset (parity with
56
+ # Python's `os.environ.get(...) or ...` truthiness).
57
+ def self.env_value(name)
58
+ value = ENV[name]
59
+ return nil if value.nil? || value.empty?
60
+
61
+ value
62
+ end
63
+ private_class_method :env_value
64
+
28
65
  def enqueue(message)
29
66
  @producer.produce(
30
67
  topic: message.topic,
@@ -920,7 +920,12 @@ module Tina4
920
920
  Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
921
921
  end
922
922
 
923
- ws.handle_upgrade(env, socket, manager: manager)
923
+ # Per-route auth on the upgrade: a secured WS route (auth_required) needs a
924
+ # valid JWT or the handshake is rejected (401, not accepted) by
925
+ # handle_upgrade — after the origin allow-list, before the handshake. The
926
+ # dev-reload channel is always public.
927
+ auth_required = !dev_reload && ws_route.respond_to?(:auth_required) && ws_route.auth_required
928
+ ws.handle_upgrade(env, socket, manager: manager, auth_required: auth_required)
924
929
 
925
930
  # Return async response (-1 signals Rack the response is handled via hijack)
926
931
  [-1, {}, []]
data/lib/tina4/router.rb CHANGED
@@ -214,15 +214,33 @@ module Tina4
214
214
  # A registered WebSocket route with path pattern matching (reuses Route's compile logic)
215
215
  class WebSocketRoute
216
216
  attr_reader :path, :handler, :path_regex, :param_names
217
+ # PUBLIC by default (mirrors GET). Flip to true with #secure (or via
218
+ # Tina4.secure_websocket) to require a valid JWT on the upgrade. Mirrors the
219
+ # HTTP Route's auth_required so the upgrade path enforces it identically.
220
+ attr_accessor :auth_required
217
221
 
218
- def initialize(path, handler)
222
+ def initialize(path, handler, auth_required: false)
219
223
  @path = normalize_path(path).freeze
220
224
  @handler = handler
225
+ @auth_required = auth_required
221
226
  @param_names = []
222
227
  @path_regex = compile_pattern(@path)
223
228
  @param_names.freeze
224
229
  end
225
230
 
231
+ # Mark this WebSocket route as requiring bearer-token auth on the upgrade.
232
+ # Returns self for chaining: Tina4::Router.websocket("/chat") { ... }.secure
233
+ def secure
234
+ @auth_required = true
235
+ self
236
+ end
237
+
238
+ # Opt back out (the default). Returns self for chaining.
239
+ def no_auth
240
+ @auth_required = false
241
+ self
242
+ end
243
+
226
244
  # Returns params hash if matched, false otherwise
227
245
  def match?(request_path)
228
246
  match = @path_regex.match(request_path)
@@ -295,13 +313,25 @@ module Tina4
295
313
  # connection — WebSocketConnection with #send, #broadcast, #close, #params
296
314
  # event — :open, :message, or :close
297
315
  # data — String payload for :message, nil for :open/:close
298
- def websocket(path, &block)
299
- ws_route = WebSocketRoute.new(path, block)
316
+ #
317
+ # PUBLIC by default (mirrors GET). Pass secure: true (the declarative way)
318
+ # OR chain .secure on the returned route (the imperative way) to require a
319
+ # valid JWT on the upgrade — both set the same auth_required flag, exactly
320
+ # like the HTTP routes support both a decorator/docblock and .secure.
321
+ def websocket(path, secure: false, &block)
322
+ ws_route = WebSocketRoute.new(path, block, auth_required: secure)
300
323
  ws_routes << ws_route
301
- Tina4::Log.debug("WebSocket route registered: #{path}")
324
+ Tina4::Log.debug("WebSocket route registered: #{path}#{secure ? ' (secured)' : ''}")
302
325
  ws_route
303
326
  end
304
327
 
328
+ # Register a SECURED WebSocket route (auth required on the upgrade). The
329
+ # declarative sibling of Tina4::Router.websocket(...).secure — mirrors the
330
+ # secure_get/secure_post pair for HTTP routes.
331
+ def secure_websocket(path, &block)
332
+ websocket(path, secure: true, &block)
333
+ end
334
+
305
335
  # Find a matching WebSocket route for a given path.
306
336
  # Returns [ws_route, params] or nil.
307
337
  def find_ws_route(path)
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.38"
4
+ VERSION = "3.13.39"
5
5
  end
@@ -42,6 +42,79 @@ module Tina4
42
42
  allowed.include?(origin)
43
43
  end
44
44
 
45
+ # Extract a bearer token from a WebSocket upgrade handshake.
46
+ #
47
+ # Order (mirrors Python's tina4_python.websocket.ws_token):
48
+ # 1. the Authorization: Bearer <jwt> header (server/CLI/mobile clients)
49
+ # 2. the Sec-WebSocket-Protocol subprotocol in the form "bearer, <jwt>"
50
+ # (the only way a *browser* can pass a token — new WebSocket() cannot set
51
+ # headers, but it CAN offer subprotocols)
52
+ # 3. a ?token=<jwt> query-string param
53
+ # Returns the token String, or nil.
54
+ #
55
+ # +headers+ is a Hash. Lookups are case-insensitive across both the Rack-style
56
+ # "HTTP_AUTHORIZATION"/"HTTP_SEC_WEBSOCKET_PROTOCOL" keys and plain
57
+ # "authorization"/"Authorization"/"sec-websocket-protocol" keys so the same
58
+ # helper serves the rack_app upgrade path and direct callers/tests.
59
+ def self.ws_token(headers, query_string = "", subprotocol = "")
60
+ headers ||= {}
61
+ auth = headers["HTTP_AUTHORIZATION"] || headers["authorization"] || headers["Authorization"] || ""
62
+ if auth[0, 7].to_s.downcase == "bearer "
63
+ tok = auth[7..].to_s.strip
64
+ return tok.empty? ? nil : tok
65
+ end
66
+
67
+ proto = subprotocol.to_s
68
+ proto = headers["HTTP_SEC_WEBSOCKET_PROTOCOL"] || headers["sec-websocket-protocol"] ||
69
+ headers["Sec-WebSocket-Protocol"] || "" if proto.empty?
70
+ parts = proto.to_s.split(",").map(&:strip).reject(&:empty?)
71
+ if parts.length >= 2 && parts[0].downcase == "bearer"
72
+ return parts[1].empty? ? nil : parts[1]
73
+ end
74
+
75
+ qs = query_string.to_s
76
+ qs = headers["QUERY_STRING"].to_s if qs.empty?
77
+ unless qs.empty?
78
+ tok = qs.split("&").each_with_object(nil) do |pair, _acc|
79
+ k, v = pair.split("=", 2)
80
+ break v if k == "token"
81
+ end
82
+ return tok unless tok.nil? || tok.to_s.empty?
83
+ end
84
+
85
+ nil
86
+ end
87
+
88
+ # Per-route WebSocket authentication, checked on the upgrade.
89
+ #
90
+ # A route is secured when it requires auth (the WebSocketRoute's #auth_required
91
+ # is truthy — set by .secure on the route or by Tina4.secure_websocket). Public
92
+ # routes (the default) always pass. A secured route needs a valid JWT via the
93
+ # Authorization header, the "bearer" subprotocol, or ?token=.
94
+ #
95
+ # Returns [payload, ok] — the verified token payload (or nil) and whether the
96
+ # upgrade may proceed. Mirrors Python's ws_authorized.
97
+ def self.ws_authorized(auth_required, headers, query_string = "", subprotocol = "")
98
+ return [nil, true] unless auth_required
99
+
100
+ token = ws_token(headers, query_string, subprotocol)
101
+ return [nil, false] unless token
102
+
103
+ payload = Tina4::Auth.valid_token(token)
104
+ [payload, !payload.nil?]
105
+ end
106
+
107
+ # Whether the client offered the "bearer" subprotocol — in which case the
108
+ # handshake response must echo "bearer" as the accepted subprotocol (browsers
109
+ # reject a 101 that doesn't echo back a subprotocol they offered).
110
+ def self.ws_bearer_subprotocol_offered?(headers)
111
+ headers ||= {}
112
+ proto = headers["HTTP_SEC_WEBSOCKET_PROTOCOL"] || headers["sec-websocket-protocol"] ||
113
+ headers["Sec-WebSocket-Protocol"] || ""
114
+ parts = proto.to_s.split(",").map(&:strip).reject(&:empty?)
115
+ !parts.empty? && parts[0].downcase == "bearer"
116
+ end
117
+
45
118
  # Build a WebSocket frame (server→client, never masked).
46
119
  def self.build_frame(opcode, data, fin: true)
47
120
  first_byte = (fin ? 0x80 : 0x00) | opcode
@@ -451,7 +524,9 @@ module Tina4
451
524
  # conn.on_close { puts "bye" }
452
525
  # end
453
526
  #
454
- def self.route(path, &block)
527
+ # PUBLIC by default (mirrors GET). Pass secure: true to require a valid JWT
528
+ # on the upgrade (or chain .secure on the returned route).
529
+ def self.route(path, secure: false, &block)
455
530
  @route_handlers ||= {}
456
531
  @route_handlers[path] = block
457
532
 
@@ -467,7 +542,7 @@ module Tina4
467
542
  end
468
543
  end
469
544
 
470
- Tina4::Router.websocket(path, &adapter)
545
+ Tina4::Router.websocket(path, secure: secure, &adapter)
471
546
  end
472
547
 
473
548
  # Upgrade a raw socket to a WebSocket connection and run its frame loop.
@@ -477,7 +552,7 @@ module Tina4
477
552
  # (Rack) mode the rack_app passes a process-wide shared engine here so that
478
553
  # broadcasts, rooms and the backplane span every route's connections even
479
554
  # though each upgrade keeps its own isolated event handlers on +self+.
480
- def handle_upgrade(env, socket, manager: self)
555
+ def handle_upgrade(env, socket, manager: self, auth_required: false)
481
556
  key = env["HTTP_SEC_WEBSOCKET_KEY"]
482
557
  return unless key
483
558
 
@@ -490,11 +565,30 @@ module Tina4
490
565
  return
491
566
  end
492
567
 
568
+ # Per-route auth — checked AFTER the origin allow-list and BEFORE we accept
569
+ # the handshake. A PUBLIC route (the default, mirrors GET) always passes; a
570
+ # secured route (auth_required) needs a valid JWT via the Authorization
571
+ # header, the "bearer" subprotocol, or ?token=. Missing/invalid → reject
572
+ # the upgrade with a 401 (close code 1008 equivalent) and never accept.
573
+ payload, ok = Tina4.ws_authorized(auth_required, env, env["QUERY_STRING"].to_s)
574
+ unless ok
575
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") rescue nil
576
+ socket.close rescue nil
577
+ return
578
+ end
579
+
493
580
  accept = Tina4.compute_accept_key(key)
494
581
 
582
+ # When the client offered the "bearer" subprotocol (the browser transport,
583
+ # since new WebSocket() can't set headers), echo "bearer" back as the
584
+ # accepted subprotocol — browsers reject a 101 that doesn't echo a
585
+ # subprotocol they offered. Mirrors Python's accept-subprotocol behaviour.
586
+ subproto_header = Tina4.ws_bearer_subprotocol_offered?(env) ? "Sec-WebSocket-Protocol: bearer\r\n" : ""
587
+
495
588
  response = "HTTP/1.1 101 Switching Protocols\r\n" \
496
589
  "Upgrade: websocket\r\n" \
497
590
  "Connection: Upgrade\r\n" \
591
+ "#{subproto_header}" \
498
592
  "Sec-WebSocket-Accept: #{accept}\r\n\r\n"
499
593
 
500
594
  socket.write(response)
@@ -502,6 +596,9 @@ module Tina4
502
596
  conn_id = SecureRandom.hex(16)
503
597
  ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
504
598
  connection = WebSocketConnection.new(conn_id, socket, ws_server: manager, path: ws_path)
599
+ # Expose the verified token payload on the connection (nil on public
600
+ # routes). Mirrors Python's connection.auth = payload.
601
+ connection.auth = payload
505
602
  manager.register_connection(connection)
506
603
 
507
604
  # Start the idle reaper lazily once we actually have a connection (opt-in
@@ -548,7 +645,8 @@ module Tina4
548
645
 
549
646
  class WebSocketConnection
550
647
  attr_reader :id, :rooms, :last_activity
551
- attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler
648
+ attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler,
649
+ :auth
552
650
 
553
651
  def initialize(id, socket, ws_server: nil, path: "/")
554
652
  @id = id
@@ -556,6 +654,9 @@ module Tina4
556
654
  @params = {}
557
655
  @ws_server = ws_server
558
656
  @path = path
657
+ # Verified JWT payload on a secured WS route, else nil (public route).
658
+ # Mirrors Python's connection.auth.
659
+ @auth = nil
559
660
  @rooms = Set.new
560
661
  @on_message_handler = nil
561
662
  @on_close_handler = nil
data/lib/tina4.rb CHANGED
@@ -212,6 +212,10 @@ module Tina4
212
212
  # Auto-discover routes
213
213
  auto_discover(root_dir)
214
214
 
215
+ # Apply pending DB migrations on startup (non-breaking — see method doc).
216
+ # Runs AFTER route discovery / DB bind, BEFORE serving.
217
+ auto_migrate_on_startup!(root_dir)
218
+
215
219
  Tina4::Log.info("Tina4 initialized successfully")
216
220
  end
217
221
 
@@ -383,9 +387,17 @@ module Tina4
383
387
  Tina4::Router.group(prefix, auth_handler: auth, &block)
384
388
  end
385
389
 
386
- # WebSocket route registration
387
- def websocket(path, &block)
388
- Tina4::Router.websocket(path, &block)
390
+ # WebSocket route registration. PUBLIC by default (mirrors GET). Pass
391
+ # secure: true OR chain .secure on the returned route to require a valid JWT
392
+ # on the upgrade.
393
+ def websocket(path, secure: false, &block)
394
+ Tina4::Router.websocket(path, secure: secure, &block)
395
+ end
396
+
397
+ # Register a SECURED WebSocket route — declarative sibling of
398
+ # Tina4.websocket(...).secure, mirroring secure_get/secure_post.
399
+ def secure_websocket(path, &block)
400
+ Tina4::Router.secure_websocket(path, &block)
389
401
  end
390
402
 
391
403
  # Middleware hooks
@@ -440,8 +452,73 @@ module Tina4
440
452
  Tina4::Container.get(name)
441
453
  end
442
454
 
455
+ # Apply pending DB migrations on startup — NON-BREAKING. Public so it can be
456
+ # called explicitly (and unit-tested) as `Tina4.auto_migrate_on_startup!`.
457
+ #
458
+ # When a migrations/ folder exists (with at least one .sql file) and
459
+ # TINA4_AUTO_MIGRATE is not disabled, pending migrations are applied during
460
+ # boot so the schema is current with no manual `tina4ruby migrate` step. A
461
+ # failure here is logged LOUD and the service STILL starts — a bad migration
462
+ # must never take the backend down. (The explicit `tina4ruby migrate` CLI
463
+ # stays fail-fast so CI still gets a non-zero exit.)
464
+ #
465
+ # Disable with TINA4_AUTO_MIGRATE=false (also 0/no/off) — e.g. multi-instance
466
+ # production that migrates as a separate deploy step (concurrent first-apply
467
+ # can race).
468
+ def auto_migrate_on_startup!(root_dir = Dir.pwd)
469
+ # Gate 1: a migrations folder with at least one .sql file must exist.
470
+ migrations_dir = resolve_startup_migrations_dir(root_dir)
471
+ return unless migrations_dir
472
+
473
+ # Gate 2: TINA4_AUTO_MIGRATE not disabled (default "true"; false/0/no/off off).
474
+ unless Tina4::Env.is_truthy(ENV.fetch("TINA4_AUTO_MIGRATE", "true"))
475
+ Tina4::Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations")
476
+ return
477
+ end
478
+
479
+ # Gate 3: a database must be resolvable.
480
+ db = Tina4.database
481
+ unless db
482
+ Tina4::Log.debug("Startup migrations skipped (no database configured)")
483
+ return
484
+ end
485
+
486
+ begin
487
+ migration = Tina4::Migration.new(db, migrations_dir: migrations_dir)
488
+ results = migration.run
489
+ applied = Array(results).count { |r| r[:status] == "success" }
490
+ Tina4::Log.info("Applied #{applied} pending migration(s) on startup") if applied.positive?
491
+ # A migration that records as "failed" surfaces in `run`'s results but
492
+ # does NOT raise from the runner; treat a recorded failure as loud-log too.
493
+ if Array(results).any? { |r| r[:status] == "failed" }
494
+ Tina4::Log.error(
495
+ "Startup auto-migration failed — the service is starting anyway. " \
496
+ "Run `tina4ruby migrate` to retry."
497
+ )
498
+ end
499
+ rescue => e
500
+ # NON-BREAKING: never re-raise out of the startup hook.
501
+ Tina4::Log.error(
502
+ "Startup auto-migration failed: #{e.message} — the service is starting " \
503
+ "anyway. Run `tina4ruby migrate` to retry."
504
+ )
505
+ end
506
+ end
507
+
443
508
  private
444
509
 
510
+ # Resolve the migrations directory for startup auto-migration, returning it
511
+ # only when it exists AND contains at least one .sql file. Prefers
512
+ # src/migrations, falls back to migrations/ (mirrors Migration#resolve_migrations_dir).
513
+ def resolve_startup_migrations_dir(root_dir)
514
+ %w[src/migrations migrations].each do |rel|
515
+ dir = File.join(root_dir, rel)
516
+ next unless Dir.exist?(dir)
517
+ return dir unless Dir.glob(File.join(dir, "*.sql")).empty?
518
+ end
519
+ nil
520
+ end
521
+
445
522
  # Resolve auth option for route registration
446
523
  # :default => use bearer auth (default for POST/PUT/PATCH/DELETE)
447
524
  # false => no auth (public route)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.38
4
+ version: 3.13.39
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-19 00:00:00.000000000 Z
11
+ date: 2026-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack