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.
- checksums.yaml +4 -4
- data/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/cli.rb +4 -0
- data/lib/tina4/database.rb +51 -6
- data/lib/tina4/dev_admin.rb +20 -4
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +25 -5
- data/lib/tina4/metrics.rb +115 -28
- data/lib/tina4/migration.rb +107 -20
- data/lib/tina4/orm.rb +182 -21
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +6 -1
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +105 -4
- data/lib/tina4.rb +80 -3
- metadata +2 -2
data/lib/tina4/query_builder.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
326
|
-
|
|
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,
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
data/lib/tina4/websocket.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-06-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|