tina4ruby 3.13.37 → 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.
data/lib/tina4/orm.rb CHANGED
@@ -13,6 +13,26 @@ module Tina4
13
13
  name.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "")
14
14
  end
15
15
 
16
+ # Singularize a plural relationship name to derive a class name.
17
+ #
18
+ # has_many :posts → "Post", has_many :categories → "Category". The old
19
+ # derivation was a naive sub(/s$/) that turned "categories" into
20
+ # "Categorie" — a NameError waiting to happen. This handles the common
21
+ # English plural endings before falling back to stripping a trailing "s".
22
+ # (When class_name: is passed explicitly, this is never consulted.)
23
+ def self.singularize(word)
24
+ s = word.to_s
25
+ if s =~ /ies\z/i
26
+ s.sub(/ies\z/i, "y")
27
+ elsif s =~ /(ss|sh|ch|x|z)es\z/i
28
+ s.sub(/es\z/i, "")
29
+ elsif s =~ /s\z/i && s !~ /ss\z/i
30
+ s.sub(/s\z/i, "")
31
+ else
32
+ s
33
+ end
34
+ end
35
+
16
36
  class ORM
17
37
  include Tina4::FieldTypes
18
38
 
@@ -144,7 +164,12 @@ module Tina4
144
164
  def has_many(name, class_name: nil, foreign_key: nil)
145
165
  relationship_definitions[name] = {
146
166
  type: :has_many,
147
- class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
167
+ # Derive the target class from the (plural) relationship name via a
168
+ # proper singularizer — "posts" → "Post", "categories" → "Category"
169
+ # — instead of the naive sub(/s$/) that produced "Categorie". The FK
170
+ # auto-wire path (foreign_key_field) always passes class_name:
171
+ # explicitly, so this default only applies to a hand-written has_many.
172
+ class_name: class_name || Tina4.singularize(name).split("_").map(&:capitalize).join,
148
173
  foreign_key: foreign_key
149
174
  }
150
175
 
@@ -326,9 +351,17 @@ module Tina4
326
351
  result[:cnt].to_i
327
352
  end
328
353
 
354
+ # Create a new instance, save it, and return it.
355
+ #
356
+ # Returns the saved instance on success. v3.13.39: if the underlying #save
357
+ # fails (validation errors or a driver error), create returns false — it
358
+ # does NOT hand back a possibly-unsaved instance, so a failed insert can
359
+ # never masquerade as a success. The failure cause is logged and available
360
+ # on the (discarded) instance's #get_error via the same path save uses.
361
+ # Parity with the Python master.
329
362
  def create(attributes = {})
330
363
  instance = new(attributes)
331
- instance.save
364
+ return false if instance.save == false
332
365
  instance
333
366
  end
334
367
 
@@ -425,17 +458,20 @@ module Tina4
425
458
  translator_engine = %w[postgres postgresql].include?(engine) ? "postgresql" : engine
426
459
  sql = SQLTranslator.auto_increment_syntax(sql, translator_engine)
427
460
 
428
- # Don't claim success when the DDL failed. db.execute() swallows the
429
- # driver error into get_error() and returns false, so a bad type (or
430
- # any DDL error) used to leave create_table returning true while no
431
- # table was actually created.
432
- ok = db.execute(sql)
433
- db.commit
434
- if ok == false
435
- Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error}", { sql: sql })
436
- return false
461
+ # Don't claim success when the DDL failed. db.execute() now RAISES on a
462
+ # SQL error (it no longer swallows it into get_error() and returns
463
+ # false), so a bad type (or any DDL error) surfaces here as an
464
+ # exception. create_table keeps its documented bool contract: catch the
465
+ # raise, log the cause, and return false so callers that test the return
466
+ # still see a clean failure instead of a thrown error.
467
+ begin
468
+ db.execute(sql)
469
+ db.commit
470
+ true
471
+ rescue => e
472
+ Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
473
+ false
437
474
  end
438
- true
439
475
  end
440
476
 
441
477
  def scope(name, filter_sql, params = [])
@@ -470,6 +506,17 @@ module Tina4
470
506
  select_one(sql, [id])
471
507
  end
472
508
 
509
+ # Return true if a record with the given primary key exists.
510
+ #
511
+ # Cross-framework parity with Python's MyModel.exists(pk_value),
512
+ # PHP's Model::exists($id), and Node's Model.exists(pk). Honours the
513
+ # soft-delete filter the same way find_by_id does (it routes through it).
514
+ # Used by #save to decide INSERT vs UPDATE for natural (non-auto-increment)
515
+ # primary keys — see the note on #save.
516
+ def exists(id)
517
+ !find_by_id(id).nil?
518
+ end
519
+
473
520
  # Clear the relationship cache on all loaded instances (class-level helper).
474
521
  # Useful after bulk operations when you want to force relationship re-loads.
475
522
  def clear_rel_cache # -> nil
@@ -533,6 +580,11 @@ module Tina4
533
580
  def initialize(attributes = {})
534
581
  @persisted = false
535
582
  @errors = []
583
+ # Cause of the most recent failed #save (validation message or DB error).
584
+ # nil when the most recent save succeeded. Mirrors db.get_error so a caller
585
+ # that checks `return false unless model.save` can still recover the real
586
+ # cause via #get_error / #last_error — the failure never vanishes silently.
587
+ @last_error = nil
536
588
  @relationship_cache = {}
537
589
  # Accept a JSON object string (parity with Python/PHP/Node):
538
590
  # Widget.new('{"id":1,"name":"alpha"}')
@@ -563,42 +615,137 @@ module Tina4
563
615
  end
564
616
  end
565
617
 
618
+ # Insert or update. Returns self on success (fluent), false on failure.
619
+ #
620
+ # Fails loud, never silent (the same principle db.execute already follows
621
+ # by raising). On *any* failure path save returns false — keeping the
622
+ # contract callers rely on (`return false unless model.save`) — but it also
623
+ # (a) logs the real cause via Tina4::Log.error with model/table context and
624
+ # (b) records the cause on a retrievable per-model error (#last_error /
625
+ # #get_error, mirroring db.get_error) plus #errors, so a caller can recover
626
+ # it after the fact. It never raises and never changes the self/false
627
+ # return shape. On success it returns self (was `true` pre-v3.13.39 — Ruby
628
+ # was the sole framework returning a bare boolean here) and clears the
629
+ # error.
630
+ #
631
+ # Two distinct failure paths, both loud:
632
+ #
633
+ # * Validation (v3.13.39): #validate runs FIRST. If it returns errors,
634
+ # save records them on @errors + @last_error, logs them, and returns
635
+ # false WITHOUT touching the database — an invalid model never reaches
636
+ # the driver. (Ruby already enforced validate-on-save; this adds the
637
+ # loud log + recoverable last_error to the failure path.)
638
+ # * Database (v3.13.39): a driver error (NOT NULL, duplicate PK, missing
639
+ # table, …) is rolled back by db.transaction, then captured (db.get_error
640
+ # falling back to the exception text) onto @last_error, logged with
641
+ # model/table context, and returns false — the cause is no longer
642
+ # swallowed silently.
643
+ #
644
+ # INSERT vs UPDATE (bug B, parity with the Python master): for a NATURAL
645
+ # (non-auto-increment) primary key that is set, the decision is made on
646
+ # whether the ROW EXISTS (via self.class.exists), not on @persisted alone.
647
+ # Pre-v3.13.39 a re-save of a manually-PK'd record that had @persisted set
648
+ # would UPDATE — but a freshly built (not-yet-persisted) natural-key record
649
+ # whose row already existed could double-INSERT, or a `new`-then-`save` of a
650
+ # natural key would INSERT then a second save UPDATE a phantom. Probing
651
+ # existence makes the choice correct regardless of @persisted. Auto-increment
652
+ # PKs keep the legacy @persisted-based decision (a nil PK means "new row,
653
+ # let the engine assign an id").
566
654
  def save
567
655
  @errors = []
568
656
  @relationship_cache = {} # Clear relationship cache on save
569
- validate_fields
570
- return false unless @errors.empty?
657
+
658
+ # ── validate() is ENFORCED. An invalid model never reaches the driver —
659
+ # fail loud (record + log), return false. ──
660
+ validation_errors = validate
661
+ unless validation_errors.empty?
662
+ @errors = validation_errors
663
+ @last_error = validation_errors.join("; ")
664
+ Tina4::Log.error(
665
+ "#{self.class.name}.save refused: validation failed — #{@last_error}"
666
+ )
667
+ return false
668
+ end
571
669
 
572
670
  data = to_db_hash(exclude_nil: true)
573
671
  pk = self.class.primary_key_field || :id
574
672
  pk_value = __send__(pk)
575
-
576
- self.class.db.transaction do |db|
577
- if @persisted && pk_value
578
- filter = { pk => pk_value }
579
- data.delete(pk)
580
- # Remove mapped primary key too
581
- mapped_pk = self.class.field_mapping[pk.to_s]
582
- data.delete(mapped_pk.to_sym) if mapped_pk
583
- db.update(self.class.table_name, data, filter)
673
+ pk_opts = self.class.field_definitions[pk] || {}
674
+ auto_increment = pk_opts[:auto_increment]
675
+
676
+ # Decide INSERT vs UPDATE.
677
+ is_update =
678
+ if pk_value.nil?
679
+ false
680
+ elsif auto_increment
681
+ # Auto-increment: legacy behaviour — a set PK on a persisted instance
682
+ # means UPDATE.
683
+ @persisted ? true : false
584
684
  else
585
- result = db.insert(self.class.table_name, data)
586
- if result[:last_id] && respond_to?("#{pk}=")
587
- __send__("#{pk}=", result[:last_id])
685
+ # Natural key: probe row existence so a re-save never double-inserts
686
+ # and a first save of a never-seen key still inserts. If the probe
687
+ # itself fails (e.g. table missing), fall back to INSERT so the caller
688
+ # sees the real driver error rather than a silent no-op UPDATE.
689
+ begin
690
+ self.class.exists(pk_value)
691
+ rescue StandardError
692
+ false
588
693
  end
589
- @persisted = true
590
694
  end
695
+
696
+ begin
697
+ self.class.db.transaction do |db|
698
+ if is_update
699
+ filter = { pk => pk_value }
700
+ data.delete(pk)
701
+ # Remove mapped primary key too
702
+ mapped_pk = self.class.field_mapping[pk.to_s]
703
+ data.delete(mapped_pk.to_sym) if mapped_pk
704
+ db.update(self.class.table_name, data, filter)
705
+ else
706
+ result = db.insert(self.class.table_name, data)
707
+ # Only adopt the engine-assigned id for an auto-increment PK. A
708
+ # natural-key PK was set by the caller; don't overwrite it with the
709
+ # driver's last_insert_id (which may be a sequence value that
710
+ # doesn't apply here).
711
+ if auto_increment && result[:last_id] && respond_to?("#{pk}=")
712
+ __send__("#{pk}=", result[:last_id])
713
+ end
714
+ end
715
+ end
716
+ rescue => e
717
+ # ── Fail loud, never silent. db.transaction already rolled back and
718
+ # re-raised. Keep the false return contract, but capture the REAL cause
719
+ # (prefer db.get_error, which insert/update/execute populate, falling
720
+ # back to the exception text) on @last_error + @errors so it survives,
721
+ # and log it with model/table context. ──
722
+ cause = (self.class.db.get_error rescue nil) || e.message
723
+ @last_error = cause
724
+ @errors = [cause]
725
+ Tina4::Log.error(
726
+ "#{self.class.name}.save failed for table " \
727
+ "'#{self.class.table_name}': #{cause}"
728
+ )
729
+ return false
591
730
  end
592
- true
593
- rescue => e
594
- @errors << e.message
595
- false
731
+
732
+ @persisted = true
733
+ @last_error = nil
734
+ self
596
735
  end
597
736
 
737
+ # Delete this record (soft or hard).
738
+ #
739
+ # v3.13.39 (bug D): RAISES on a missing primary key, matching #force_delete
740
+ # (which already raised). Previously delete returned false on a nil PK while
741
+ # force_delete raised — an inconsistent contract where "couldn't delete" and
742
+ # "deleted nothing" were indistinguishable on one path but loud on the other.
743
+ # Both now fail loud: deleting a record with no PK is a programmer error, not
744
+ # a quiet no-op. Returns true on a successful delete.
598
745
  def delete
599
746
  pk = self.class.primary_key_field || :id
600
747
  pk_value = __send__(pk)
601
- return false unless pk_value
748
+ raise "Cannot delete: no primary key value" unless pk_value
602
749
 
603
750
  self.class.db.transaction do |db|
604
751
  if self.class.soft_delete
@@ -700,6 +847,23 @@ module Tina4
700
847
  @errors
701
848
  end
702
849
 
850
+ # Cause of the most recent failed #save (validation message or DB error),
851
+ # or nil when the last save succeeded.
852
+ def last_error
853
+ @last_error
854
+ end
855
+
856
+ # Return the cause of the most recent failed #save, or nil.
857
+ #
858
+ # Mirrors db.get_error. After save returns false — whether from validation
859
+ # or a driver error — the real cause is retrievable here (and on
860
+ # #last_error) so a caller using the `return false unless model.save`
861
+ # contract can still surface it. Cleared to nil on a successful save.
862
+ # Cross-framework parity with Python/PHP/Node get_error().
863
+ def get_error
864
+ @last_error
865
+ end
866
+
703
867
  # Convert to hash using Ruby attribute names.
704
868
  # Optionally include relationships via the include keyword.
705
869
  # case: "camel" converts snake_case keys to camelCase (parity with
@@ -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,
@@ -351,9 +351,12 @@ module Tina4
351
351
  env["tina4.request"] = request # Store for session save after response
352
352
  response = Tina4::Response.new
353
353
 
354
- # Run global middleware (block-based + class-based before_* methods)
354
+ # Run global middleware (block-based + class-based before_* methods).
355
+ # M2 — AFTER-ON-4xx RULE: when a before_* short-circuits (4xx/skip) or
356
+ # throws (clean 500), the after-pass STILL runs so after_* can add
357
+ # headers/logging — consistent across all 4 frameworks.
355
358
  unless Tina4::Middleware.run_before(Tina4::Middleware.global_middleware, request, response)
356
- # Middleware halted the request -- return whatever response was set
359
+ Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, response)
357
360
  return response.to_rack
358
361
  end
359
362
 
@@ -885,18 +888,23 @@ module Tina4
885
888
  # Wire the route handler into the WebSocket engine events
886
889
  handler = ws_route.handler
887
890
 
888
- # Create a dedicated WebSocket engine for this route so handlers stay isolated
891
+ # Create a dedicated WebSocket *event* engine for this route so each
892
+ # upgrade keeps its own isolated open/message/close handlers (the engine's
893
+ # on() appends handlers, so a single shared event engine would cross-wire
894
+ # routes).
889
895
  ws = Tina4::WebSocket.new
890
896
 
891
- # The dev-reload channel is held by a process-wide shared manager so a
892
- # broadcast from POST /__dev/api/reload reaches every browser, not just
893
- # the connections of this one isolated per-socket engine. Mirrors
894
- # Python's single _ws_manager keyed on the /__dev_reload path.
897
+ # The connection itself is OWNED by a process-wide shared manager so that
898
+ # broadcasts, rooms, the multi-instance backplane and the idle reaper span
899
+ # every route's connections not just this one per-socket event engine.
900
+ # Mirrors Python's single WebSocketManager. The dev-reload channel uses its
901
+ # own shared manager (Tina4::DevReload) so POST /__dev/api/reload reaches
902
+ # every browser.
895
903
  dev_reload = ws_route.path == "/__dev_reload"
904
+ manager = dev_reload ? Tina4::DevReload.manager : @websocket_engine
896
905
 
897
906
  ws.on(:open) do |connection|
898
907
  connection.params = ws_params
899
- Tina4::DevReload.add(connection) if dev_reload
900
908
  handler.call(connection, :open, nil)
901
909
  end
902
910
 
@@ -905,7 +913,6 @@ module Tina4
905
913
  end
906
914
 
907
915
  ws.on(:close) do |connection|
908
- Tina4::DevReload.remove(connection) if dev_reload
909
916
  handler.call(connection, :close, nil)
910
917
  end
911
918
 
@@ -913,7 +920,12 @@ module Tina4
913
920
  Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
914
921
  end
915
922
 
916
- ws.handle_upgrade(env, socket)
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)
917
929
 
918
930
  # Return async response (-1 signals Rack the response is handled via hijack)
919
931
  [-1, {}, []]
@@ -347,18 +347,38 @@ module Tina4
347
347
  gen = @_stream_generator
348
348
  blk = @_stream_block
349
349
  body = Enumerator.new do |yielder|
350
- if gen
351
- if gen.respond_to?(:each)
352
- # Enumerator / array / any Enumerable of string chunks
353
- gen.each { |chunk| yielder << chunk }
354
- elsif gen.respond_to?(:call)
355
- # Callable that receives the yielder, like the block form
356
- gen.call(yielder)
357
- else
358
- yielder << gen.to_s
350
+ # SSE hardening: a streaming source that raises mid-stream (a
351
+ # generator/block error, or the client disconnecting and the server
352
+ # tearing the body down) must NEVER crash the worker. We catch the
353
+ # error, log it, and end the stream cleanly — the chunks emitted
354
+ # before the failure are still delivered.
355
+ #
356
+ # A client disconnect surfaces in a hijack/Puma streaming body as a
357
+ # write-side IOError/Errno on the socket; that is propagated up as a
358
+ # normal stop and re-raised so Rack/Puma can close the connection,
359
+ # while a *source* error is swallowed after logging.
360
+ begin
361
+ if gen
362
+ if gen.respond_to?(:each)
363
+ # Enumerator / array / any Enumerable of string chunks
364
+ gen.each { |chunk| yielder << chunk }
365
+ elsif gen.respond_to?(:call)
366
+ # Callable that receives the yielder, like the block form
367
+ gen.call(yielder)
368
+ else
369
+ yielder << gen.to_s
370
+ end
371
+ elsif blk
372
+ blk.call(yielder)
359
373
  end
360
- elsif blk
361
- blk.call(yielder)
374
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
375
+ # Client disconnected mid-stream — stop cleanly, do not crash, and
376
+ # do not log loudly (a normal browser closing an SSE stream).
377
+ Tina4::Log.debug("SSE/stream client disconnected: #{e.class}: #{e.message}") if defined?(Tina4::Log)
378
+ rescue StandardError => e
379
+ # The source (generator/block) itself raised mid-stream. Log it and
380
+ # end the stream cleanly rather than crashing the handler/worker.
381
+ Tina4::Log.error("SSE/stream source error: #{e.class}: #{e.message}") if defined?(Tina4::Log)
362
382
  end
363
383
  end
364
384
  return [@status_code, final_headers, body]
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)