tina4ruby 3.13.38 → 3.13.40
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/auth.rb +5 -1
- data/lib/tina4/cli.rb +4 -0
- data/lib/tina4/database.rb +51 -6
- data/lib/tina4/dev_admin.rb +92 -9
- data/lib/tina4/docstore.rb +753 -0
- data/lib/tina4/env.rb +7 -2
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +101 -9
- 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 +22 -5
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/swagger.rb +166 -32
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +105 -4
- data/lib/tina4.rb +81 -3
- metadata +3 -2
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/swagger.rb
CHANGED
|
@@ -7,14 +7,28 @@ module Tina4
|
|
|
7
7
|
def generate(routes = [])
|
|
8
8
|
spec = base_spec
|
|
9
9
|
route_list = routes.empty? ? Tina4::Router.routes : routes
|
|
10
|
+
# Accumulators shared across routes: ORM models referenced
|
|
11
|
+
# (-> components.schemas), tags used (-> top-level tags[]), seen
|
|
12
|
+
# operationIds (de-dup — OpenAPI requires them unique).
|
|
13
|
+
ctx = { models: {}, used_tags: [], seen_ids: [] }
|
|
10
14
|
route_list.each do |route|
|
|
11
|
-
add_route_to_spec(spec, route)
|
|
15
|
+
add_route_to_spec(spec, route, ctx)
|
|
12
16
|
end
|
|
17
|
+
|
|
18
|
+
unless ctx[:models].empty?
|
|
19
|
+
spec["components"]["schemas"] = {}
|
|
20
|
+
ctx[:models].each do |name, klass|
|
|
21
|
+
spec["components"]["schemas"][name] = model_schema(klass)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
spec["tags"] = ctx[:used_tags].map { |t| { "name" => t } } unless ctx[:used_tags].empty?
|
|
25
|
+
|
|
13
26
|
spec
|
|
14
27
|
end
|
|
15
28
|
|
|
16
|
-
# TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG.
|
|
17
|
-
#
|
|
29
|
+
# TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. Wired into RackApp's
|
|
30
|
+
# /swagger serving (v3.13.40), so it genuinely gates whether the docs are
|
|
31
|
+
# served — it was dead code before.
|
|
18
32
|
def enabled?
|
|
19
33
|
explicit = ENV["TINA4_SWAGGER_ENABLED"]
|
|
20
34
|
if explicit && !explicit.empty?
|
|
@@ -51,9 +65,7 @@ module Tina4
|
|
|
51
65
|
{
|
|
52
66
|
"openapi" => "3.0.3",
|
|
53
67
|
"info" => info,
|
|
54
|
-
"servers" =>
|
|
55
|
-
{ "url" => "/" }
|
|
56
|
-
],
|
|
68
|
+
"servers" => servers,
|
|
57
69
|
"paths" => {},
|
|
58
70
|
"components" => {
|
|
59
71
|
"securitySchemes" => {
|
|
@@ -67,48 +79,125 @@ module Tina4
|
|
|
67
79
|
}
|
|
68
80
|
end
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
# servers[] — TINA4_SWAGGER_SERVERS (comma-separated) for a multi-server
|
|
83
|
+
# list, else SWAGGER_DEV_URL, else the relative "/" default.
|
|
84
|
+
def servers
|
|
85
|
+
raw = ENV.fetch("TINA4_SWAGGER_SERVERS", "")
|
|
86
|
+
urls = raw.split(",").map(&:strip).reject(&:empty?)
|
|
87
|
+
return urls.map { |u| { "url" => u } } unless urls.empty?
|
|
88
|
+
|
|
89
|
+
dev = ENV["SWAGGER_DEV_URL"]
|
|
90
|
+
return [{ "url" => dev }] if dev && !dev.empty?
|
|
91
|
+
|
|
92
|
+
[{ "url" => "/" }]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Valid OpenAPI path-item methods. Anything else (e.g. "any", a WebSocket
|
|
96
|
+
# "ws") is not a valid key and would make the document spec-invalid.
|
|
97
|
+
HTTP_METHODS = %w[get post put patch delete head options trace].freeze
|
|
98
|
+
|
|
99
|
+
def add_route_to_spec(spec, route, ctx)
|
|
72
100
|
method = route.method.downcase
|
|
73
|
-
return
|
|
101
|
+
return unless HTTP_METHODS.include?(method)
|
|
102
|
+
|
|
103
|
+
path = convert_path(route.path)
|
|
104
|
+
meta = route.swagger_meta || {}
|
|
105
|
+
|
|
106
|
+
# ORM model -> components.schemas + $ref
|
|
107
|
+
ref = nil
|
|
108
|
+
if (model = meta[:model])
|
|
109
|
+
klass = model.is_a?(String) ? resolve_model(model) : model
|
|
110
|
+
if klass
|
|
111
|
+
name = klass.name.split("::").last
|
|
112
|
+
ctx[:models][name] ||= klass
|
|
113
|
+
ref = "#/components/schemas/#{name}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
tags = meta[:tags] || [extract_tag(route.path)]
|
|
118
|
+
tags.each { |t| ctx[:used_tags] << t unless ctx[:used_tags].include?(t) }
|
|
74
119
|
|
|
75
120
|
spec["paths"][path] ||= {}
|
|
76
121
|
operation = {
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
122
|
+
"operationId" => unique_operation_id(method, path, ctx[:seen_ids]),
|
|
123
|
+
"summary" => meta[:summary] || "#{method.upcase} #{route.path}",
|
|
124
|
+
"description" => meta[:description] || "",
|
|
125
|
+
"tags" => tags,
|
|
80
126
|
"parameters" => build_parameters(route),
|
|
81
|
-
"responses" =>
|
|
127
|
+
"responses" => meta[:responses] || model_or_default_responses(ref, meta[:model_list])
|
|
82
128
|
}
|
|
83
129
|
|
|
130
|
+
operation["deprecated"] = true if meta[:deprecated]
|
|
131
|
+
|
|
84
132
|
if route.auth_handler
|
|
85
133
|
operation["security"] = [{ "bearerAuth" => [] }]
|
|
86
134
|
end
|
|
87
135
|
|
|
88
|
-
if %w[post put patch].include?(method)
|
|
89
|
-
operation["requestBody"] =
|
|
90
|
-
elsif %w[post put patch].include?(method)
|
|
91
|
-
operation["requestBody"] = default_request_body
|
|
136
|
+
if %w[post put patch].include?(method)
|
|
137
|
+
operation["requestBody"] = build_request_body(method, meta, ref)
|
|
92
138
|
end
|
|
93
139
|
|
|
94
140
|
spec["paths"][path][method] = operation
|
|
95
141
|
end
|
|
96
142
|
|
|
143
|
+
def build_request_body(_method, meta, ref)
|
|
144
|
+
return meta[:request_body] if meta[:request_body]
|
|
145
|
+
|
|
146
|
+
schema = ref ? { "$ref" => ref } : { "type" => "object" }
|
|
147
|
+
content = { "schema" => schema }
|
|
148
|
+
content["example"] = meta[:example] if meta[:example]
|
|
149
|
+
{ "content" => { "application/json" => content } }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def model_or_default_responses(ref, model_list)
|
|
153
|
+
return default_responses unless ref
|
|
154
|
+
|
|
155
|
+
schema = model_list ? { "type" => "array", "items" => { "$ref" => ref } } : { "$ref" => ref }
|
|
156
|
+
{
|
|
157
|
+
"200" => {
|
|
158
|
+
"description" => "Successful response",
|
|
159
|
+
"content" => { "application/json" => { "schema" => schema } }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
97
164
|
def convert_path(path)
|
|
98
|
-
#
|
|
99
|
-
path.gsub(/\{(\w+)(?::\w+)?\}/, '{\1}')
|
|
165
|
+
# {id:int} -> {id}
|
|
166
|
+
p = path.gsub(/\{(\w+)(?::\w+)?\}/, '{\1}')
|
|
167
|
+
# splat *path -> {path}; bare /* -> /{wildcard} (a literal '*' segment
|
|
168
|
+
# or an orphaned splat param is invalid OpenAPI templating)
|
|
169
|
+
p = p.gsub(/\*(\w+)/, '{\1}')
|
|
170
|
+
p.gsub(%r{(?<=/)\*(?=/|$)}, "{wildcard}")
|
|
100
171
|
end
|
|
101
172
|
|
|
102
173
|
def extract_tag(path)
|
|
103
174
|
parts = path.split("/").reject(&:empty?)
|
|
104
|
-
parts.first
|
|
175
|
+
first = parts.first
|
|
176
|
+
return "default" if first.nil? || first.start_with?("{", "*")
|
|
177
|
+
|
|
178
|
+
first
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def unique_operation_id(method, path, seen)
|
|
182
|
+
base = method + path.gsub(%r{[/{}*]}) { |c| c == "*" ? "wildcard" : "_" }
|
|
183
|
+
base = base.gsub(/_+/, "_").chomp("_")
|
|
184
|
+
oid = base
|
|
185
|
+
n = 2
|
|
186
|
+
while seen.include?(oid)
|
|
187
|
+
oid = "#{base}_#{n}"
|
|
188
|
+
n += 1
|
|
189
|
+
end
|
|
190
|
+
seen << oid
|
|
191
|
+
oid
|
|
105
192
|
end
|
|
106
193
|
|
|
107
194
|
def build_parameters(route)
|
|
108
195
|
params = []
|
|
109
196
|
route.param_names.each do |param|
|
|
197
|
+
name = param[:name].to_s
|
|
198
|
+
name = "wildcard" if name == "*"
|
|
110
199
|
params << {
|
|
111
|
-
"name" =>
|
|
200
|
+
"name" => name,
|
|
112
201
|
"in" => "path",
|
|
113
202
|
"required" => true,
|
|
114
203
|
"schema" => param_schema(param[:type])
|
|
@@ -118,16 +207,71 @@ module Tina4
|
|
|
118
207
|
end
|
|
119
208
|
|
|
120
209
|
def param_schema(type)
|
|
121
|
-
case type
|
|
210
|
+
case type.to_s
|
|
122
211
|
when "int", "integer"
|
|
123
212
|
{ "type" => "integer" }
|
|
124
213
|
when "float", "number"
|
|
125
214
|
{ "type" => "number" }
|
|
215
|
+
when "uuid"
|
|
216
|
+
{ "type" => "string", "format" => "uuid" }
|
|
217
|
+
when "slug"
|
|
218
|
+
{ "type" => "string", "pattern" => "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
|
|
219
|
+
when "alpha"
|
|
220
|
+
{ "type" => "string", "pattern" => "^[A-Za-z]+$" }
|
|
221
|
+
when "alnum"
|
|
222
|
+
{ "type" => "string", "pattern" => "^[A-Za-z0-9]+$" }
|
|
223
|
+
else
|
|
224
|
+
{ "type" => "string" }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Build a components.schemas object from an ORM model's field definitions.
|
|
229
|
+
def model_schema(model_class)
|
|
230
|
+
props = {}
|
|
231
|
+
required = []
|
|
232
|
+
defs = model_class.respond_to?(:field_definitions) ? model_class.field_definitions : {}
|
|
233
|
+
defs.each do |name, opts|
|
|
234
|
+
props[name.to_s] = field_schema(opts)
|
|
235
|
+
required << name.to_s if opts[:nullable] == false
|
|
236
|
+
end
|
|
237
|
+
schema = { "type" => "object", "properties" => props.empty? ? {} : props }
|
|
238
|
+
schema["required"] = required unless required.empty?
|
|
239
|
+
schema
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def field_schema(opts)
|
|
243
|
+
schema = map_field_type(opts[:type])
|
|
244
|
+
schema["readOnly"] = true if opts[:primary_key] && opts[:auto_increment]
|
|
245
|
+
schema
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def map_field_type(type)
|
|
249
|
+
case type.to_s
|
|
250
|
+
when "integer"
|
|
251
|
+
{ "type" => "integer" }
|
|
252
|
+
when "float", "decimal", "numeric"
|
|
253
|
+
{ "type" => "number" }
|
|
254
|
+
when "boolean"
|
|
255
|
+
{ "type" => "boolean" }
|
|
256
|
+
when "datetime", "date", "timestamp"
|
|
257
|
+
{ "type" => "string", "format" => "date-time" }
|
|
258
|
+
when "blob"
|
|
259
|
+
{ "type" => "string", "format" => "byte" }
|
|
126
260
|
else
|
|
127
261
|
{ "type" => "string" }
|
|
128
262
|
end
|
|
129
263
|
end
|
|
130
264
|
|
|
265
|
+
def resolve_model(name)
|
|
266
|
+
Object.const_get(name)
|
|
267
|
+
rescue NameError
|
|
268
|
+
begin
|
|
269
|
+
Tina4.const_get(name)
|
|
270
|
+
rescue NameError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
131
275
|
def default_responses
|
|
132
276
|
{
|
|
133
277
|
"200" => { "description" => "Successful response" },
|
|
@@ -137,16 +281,6 @@ module Tina4
|
|
|
137
281
|
"500" => { "description" => "Internal server error" }
|
|
138
282
|
}
|
|
139
283
|
end
|
|
140
|
-
|
|
141
|
-
def default_request_body
|
|
142
|
-
{
|
|
143
|
-
"content" => {
|
|
144
|
-
"application/json" => {
|
|
145
|
-
"schema" => { "type" => "object" }
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
end
|
|
150
284
|
end
|
|
151
285
|
end
|
|
152
286
|
end
|
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
|
@@ -50,6 +50,7 @@ require_relative "tina4/error_overlay"
|
|
|
50
50
|
require_relative "tina4/test_client"
|
|
51
51
|
require_relative "tina4/test"
|
|
52
52
|
require_relative "tina4/docs"
|
|
53
|
+
require_relative "tina4/docstore"
|
|
53
54
|
require_relative "tina4/mcp"
|
|
54
55
|
|
|
55
56
|
module Tina4
|
|
@@ -212,6 +213,10 @@ module Tina4
|
|
|
212
213
|
# Auto-discover routes
|
|
213
214
|
auto_discover(root_dir)
|
|
214
215
|
|
|
216
|
+
# Apply pending DB migrations on startup (non-breaking — see method doc).
|
|
217
|
+
# Runs AFTER route discovery / DB bind, BEFORE serving.
|
|
218
|
+
auto_migrate_on_startup!(root_dir)
|
|
219
|
+
|
|
215
220
|
Tina4::Log.info("Tina4 initialized successfully")
|
|
216
221
|
end
|
|
217
222
|
|
|
@@ -383,9 +388,17 @@ module Tina4
|
|
|
383
388
|
Tina4::Router.group(prefix, auth_handler: auth, &block)
|
|
384
389
|
end
|
|
385
390
|
|
|
386
|
-
# WebSocket route registration
|
|
387
|
-
|
|
388
|
-
|
|
391
|
+
# WebSocket route registration. PUBLIC by default (mirrors GET). Pass
|
|
392
|
+
# secure: true OR chain .secure on the returned route to require a valid JWT
|
|
393
|
+
# on the upgrade.
|
|
394
|
+
def websocket(path, secure: false, &block)
|
|
395
|
+
Tina4::Router.websocket(path, secure: secure, &block)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Register a SECURED WebSocket route — declarative sibling of
|
|
399
|
+
# Tina4.websocket(...).secure, mirroring secure_get/secure_post.
|
|
400
|
+
def secure_websocket(path, &block)
|
|
401
|
+
Tina4::Router.secure_websocket(path, &block)
|
|
389
402
|
end
|
|
390
403
|
|
|
391
404
|
# Middleware hooks
|
|
@@ -440,8 +453,73 @@ module Tina4
|
|
|
440
453
|
Tina4::Container.get(name)
|
|
441
454
|
end
|
|
442
455
|
|
|
456
|
+
# Apply pending DB migrations on startup — NON-BREAKING. Public so it can be
|
|
457
|
+
# called explicitly (and unit-tested) as `Tina4.auto_migrate_on_startup!`.
|
|
458
|
+
#
|
|
459
|
+
# When a migrations/ folder exists (with at least one .sql file) and
|
|
460
|
+
# TINA4_AUTO_MIGRATE is not disabled, pending migrations are applied during
|
|
461
|
+
# boot so the schema is current with no manual `tina4ruby migrate` step. A
|
|
462
|
+
# failure here is logged LOUD and the service STILL starts — a bad migration
|
|
463
|
+
# must never take the backend down. (The explicit `tina4ruby migrate` CLI
|
|
464
|
+
# stays fail-fast so CI still gets a non-zero exit.)
|
|
465
|
+
#
|
|
466
|
+
# Disable with TINA4_AUTO_MIGRATE=false (also 0/no/off) — e.g. multi-instance
|
|
467
|
+
# production that migrates as a separate deploy step (concurrent first-apply
|
|
468
|
+
# can race).
|
|
469
|
+
def auto_migrate_on_startup!(root_dir = Dir.pwd)
|
|
470
|
+
# Gate 1: a migrations folder with at least one .sql file must exist.
|
|
471
|
+
migrations_dir = resolve_startup_migrations_dir(root_dir)
|
|
472
|
+
return unless migrations_dir
|
|
473
|
+
|
|
474
|
+
# Gate 2: TINA4_AUTO_MIGRATE not disabled (default "true"; false/0/no/off off).
|
|
475
|
+
unless Tina4::Env.is_truthy(ENV.fetch("TINA4_AUTO_MIGRATE", "true"))
|
|
476
|
+
Tina4::Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations")
|
|
477
|
+
return
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Gate 3: a database must be resolvable.
|
|
481
|
+
db = Tina4.database
|
|
482
|
+
unless db
|
|
483
|
+
Tina4::Log.debug("Startup migrations skipped (no database configured)")
|
|
484
|
+
return
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
begin
|
|
488
|
+
migration = Tina4::Migration.new(db, migrations_dir: migrations_dir)
|
|
489
|
+
results = migration.run
|
|
490
|
+
applied = Array(results).count { |r| r[:status] == "success" }
|
|
491
|
+
Tina4::Log.info("Applied #{applied} pending migration(s) on startup") if applied.positive?
|
|
492
|
+
# A migration that records as "failed" surfaces in `run`'s results but
|
|
493
|
+
# does NOT raise from the runner; treat a recorded failure as loud-log too.
|
|
494
|
+
if Array(results).any? { |r| r[:status] == "failed" }
|
|
495
|
+
Tina4::Log.error(
|
|
496
|
+
"Startup auto-migration failed — the service is starting anyway. " \
|
|
497
|
+
"Run `tina4ruby migrate` to retry."
|
|
498
|
+
)
|
|
499
|
+
end
|
|
500
|
+
rescue => e
|
|
501
|
+
# NON-BREAKING: never re-raise out of the startup hook.
|
|
502
|
+
Tina4::Log.error(
|
|
503
|
+
"Startup auto-migration failed: #{e.message} — the service is starting " \
|
|
504
|
+
"anyway. Run `tina4ruby migrate` to retry."
|
|
505
|
+
)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
443
509
|
private
|
|
444
510
|
|
|
511
|
+
# Resolve the migrations directory for startup auto-migration, returning it
|
|
512
|
+
# only when it exists AND contains at least one .sql file. Prefers
|
|
513
|
+
# src/migrations, falls back to migrations/ (mirrors Migration#resolve_migrations_dir).
|
|
514
|
+
def resolve_startup_migrations_dir(root_dir)
|
|
515
|
+
%w[src/migrations migrations].each do |rel|
|
|
516
|
+
dir = File.join(root_dir, rel)
|
|
517
|
+
next unless Dir.exist?(dir)
|
|
518
|
+
return dir unless Dir.glob(File.join(dir, "*.sql")).empty?
|
|
519
|
+
end
|
|
520
|
+
nil
|
|
521
|
+
end
|
|
522
|
+
|
|
445
523
|
# Resolve auth option for route registration
|
|
446
524
|
# :default => use bearer auth (default for POST/PUT/PATCH/DELETE)
|
|
447
525
|
# 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.40
|
|
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-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -278,6 +278,7 @@ files:
|
|
|
278
278
|
- lib/tina4/dev_admin.rb
|
|
279
279
|
- lib/tina4/dev_mailbox.rb
|
|
280
280
|
- lib/tina4/docs.rb
|
|
281
|
+
- lib/tina4/docstore.rb
|
|
281
282
|
- lib/tina4/drivers/firebird_driver.rb
|
|
282
283
|
- lib/tina4/drivers/mongodb_driver.rb
|
|
283
284
|
- lib/tina4/drivers/mssql_driver.rb
|