tina4ruby 3.13.91 → 3.13.93

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3df8f30ccbf4fc6d984ec4af8fa3262045b16ac1f05c7516f6a6818691fad547
4
- data.tar.gz: 1236689bdee9821f1994bc27d1a69684fd312cc774f33a2bba6b10fa57bd86d8
3
+ metadata.gz: f7b916f1f99717b6933e8d4376b760d2c975daefec3b5507d9827480522cfe8f
4
+ data.tar.gz: e800d0a184690267eb84976193744593eea98b2ec355405f15a32214d12e31a0
5
5
  SHA512:
6
- metadata.gz: c51b11ed1b2c9361c162ab6d54f999381d13b8b6be74f4cd94ebfa0d72492f0805f9428e9080ee06b2eb6efd52dc5fd72e1a9d073743d5ad03ec9582c3894b8b
7
- data.tar.gz: 4f8265ec9da1f6bd427ed844d808e4e2c67ee55ec934a0d24ea37f40b1a88861ce86bc9c09b162cd534f6d879825ed31a05e0baf32e73322ea8584097d9b8346
6
+ metadata.gz: 6428126904b059c914f08ca0fc446622a07447075c23e4a20ba12b01f93cf6a5c48454cab6cebfdc98da615f9fbb9ba57d4e3021d6b26d688cb92cdf5a553cb3
7
+ data.tar.gz: 7fb13ab4b8cae5c60ad932ed1a9de7750881381451b294747b6f90441b682dd5658f49394fd29707144fe2e4ae95a5639cb47cc50a6a1a87aae6b4d60eab0e29
data/README.md CHANGED
@@ -105,7 +105,7 @@ Tina4 Ruby outperforms Sinatra while delivering **98 features vs ~4**, with zero
105
105
 
106
106
  ## Cross-Framework Parity
107
107
 
108
- Tina4 ships identical features across four languages: same architecture, same conventions, same 97 features:
108
+ Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
109
109
 
110
110
  | | Python | PHP | Ruby | Node.js |
111
111
  |---|--------|-----|------|---------|
data/lib/tina4/auth.rb CHANGED
@@ -21,6 +21,23 @@ module Tina4
21
21
  "run was NOT detected as dev - typically a container or CI without " \
22
22
  "TINA4_DEBUG set, or TINA4_ENV=production."
23
23
 
24
+ # Supported JWT algorithms. HMAC only — the whole family ships in OpenSSL, so
25
+ # this stays zero-gem. The header's "alg" is now always the one we actually
26
+ # sign with: the digest is looked up here rather than hardcoded to SHA256.
27
+ # Mirrors the Python master's _HMAC_ALGORITHMS (a name -> digest map); the
28
+ # value is the digest CLASS and a fresh instance is made per signature, so
29
+ # nothing about the digest state is shared between calls or threads.
30
+ HMAC_ALGORITHMS = {
31
+ "HS256" => OpenSSL::Digest::SHA256,
32
+ "HS384" => OpenSSL::Digest::SHA384,
33
+ "HS512" => OpenSSL::Digest::SHA512
34
+ }.freeze
35
+
36
+ # Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without
37
+ # this a token minted on one host and validated on another a second behind is
38
+ # rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
39
+ JWT_LEEWAY_SECONDS = 60
40
+
24
41
  class << self
25
42
  def setup(root_dir = Dir.pwd)
26
43
  @keys_dir = File.join(root_dir, KEYS_DIR)
@@ -111,62 +128,120 @@ module Tina4
111
128
  Base64.urlsafe_decode64(str)
112
129
  end
113
130
 
114
- # Build a JWT using HS256 with Ruby's OpenSSL::HMAC (no gem needed)
115
- def hmac_encode(claims, secret)
116
- header = { "alg" => "HS256", "typ" => "JWT" }
131
+ # Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else
132
+ # HS256. A blank value counts as unset (parity with Python, where an empty
133
+ # env string is falsy and falls through).
134
+ #
135
+ # Raises ArgumentError naming the supported set when asked for one we cannot
136
+ # sign — Ruby's idiomatic equivalent of the master's ValueError. The env var
137
+ # was registered in the CLI's known_vars and then silently ignored here, so a
138
+ # user could set HS512 and still get HS256 tokens.
139
+ def resolve_algorithm(algorithm = nil)
140
+ candidate = [algorithm, ENV["TINA4_JWT_ALGORITHM"]]
141
+ .find { |value| !value.nil? && !value.to_s.strip.empty? }
142
+ chosen = (candidate || "HS256").to_s.strip
143
+ unless HMAC_ALGORITHMS.key?(chosen)
144
+ raise ArgumentError,
145
+ "Unsupported JWT algorithm #{chosen.inspect}. Tina4 signs with " \
146
+ "#{HMAC_ALGORITHMS.keys.sort.join(', ')} (HMAC only, zero-dependency). " \
147
+ "Set TINA4_JWT_ALGORITHM to one of those."
148
+ end
149
+ chosen
150
+ end
151
+
152
+ # HMAC the signing input with the digest the algorithm actually names, so the
153
+ # header's "alg" can never disagree with the bytes we produced.
154
+ def hmac_signature(algorithm, secret, signing_input)
155
+ OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input)
156
+ end
157
+
158
+ # Build a JWT with Ruby's OpenSSL::HMAC (no gem needed). The algorithm is the
159
+ # explicit argument, else TINA4_JWT_ALGORITHM, else HS256 — and the header
160
+ # advertises exactly the algorithm that signed.
161
+ def hmac_encode(claims, secret, algorithm: nil)
162
+ alg = resolve_algorithm(algorithm)
163
+ header = { "alg" => alg, "typ" => "JWT" }
117
164
  segments = [
118
165
  base64url_encode(JSON.generate(header)),
119
166
  base64url_encode(JSON.generate(claims))
120
167
  ]
121
168
  signing_input = segments.join(".")
122
- signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
123
- segments << base64url_encode(signature)
169
+ segments << base64url_encode(hmac_signature(alg, secret, signing_input))
124
170
  segments.join(".")
125
171
  end
126
172
 
127
- # Decode and verify a JWT signed with HS256. Returns the payload hash or nil.
128
- def hmac_decode(token, secret)
129
- parts = token.split(".")
130
- return nil unless parts.length == 3
131
-
132
- header_json = base64url_decode(parts[0])
133
- header = JSON.parse(header_json)
134
- return nil unless header["alg"] == "HS256"
135
-
136
- # Verify signature
137
- signing_input = "#{parts[0]}.#{parts[1]}"
138
- expected_sig = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
139
- actual_sig = base64url_decode(parts[2])
140
-
141
- # Constant-time comparison to prevent timing attacks
142
- return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
143
-
144
- payload = JSON.parse(base64url_decode(parts[1]))
145
-
146
- # Check expiry
147
- now = Time.now.to_i
148
- return nil if payload["exp"] && now >= payload["exp"]
149
- return nil if payload["nbf"] && now < payload["nbf"]
173
+ # Decode and verify an HMAC-signed JWT. Returns the payload hash or nil.
174
+ #
175
+ # The algorithm is PINNED to our configured one rather than trusted from the
176
+ # token: a header asking to be verified as anything else — "none", a
177
+ # different HMAC, or an RSA alg we do not implement here — is rejected before
178
+ # any signature work.
179
+ def hmac_decode(token, secret, algorithm: nil)
180
+ # Resolved OUTSIDE the rescue below: an unsupported algorithm is a
181
+ # configuration error that must surface, not become a nil "invalid token".
182
+ alg = resolve_algorithm(algorithm)
150
183
 
151
- payload
152
- rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
153
- nil
184
+ begin
185
+ parts = token.split(".")
186
+ return nil unless parts.length == 3
187
+
188
+ header_json = base64url_decode(parts[0])
189
+ header = JSON.parse(header_json)
190
+ return nil unless header["alg"] == alg
191
+
192
+ # Verify signature
193
+ signing_input = "#{parts[0]}.#{parts[1]}"
194
+ expected_sig = hmac_signature(alg, secret, signing_input)
195
+ actual_sig = base64url_decode(parts[2])
196
+
197
+ # Constant-time comparison to prevent timing attacks. Lengths must match
198
+ # first — fixed_length_secure_compare raises on a length mismatch, which
199
+ # a forged signature of the wrong digest size would trigger.
200
+ return nil unless expected_sig.bytesize == actual_sig.bytesize
201
+ return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
202
+
203
+ payload = JSON.parse(base64url_decode(parts[1]))
204
+
205
+ # Check expiry
206
+ now = Time.now.to_i
207
+ return nil if payload["exp"] && now >= payload["exp"]
208
+
209
+ # "nbf" (not-before): a post-dated token is not valid yet. Tolerate
210
+ # JWT_LEEWAY_SECONDS of clock skew so a token minted on a host a second
211
+ # ahead is not rejected for nothing.
212
+ return nil if payload["nbf"] && now + JWT_LEEWAY_SECONDS < payload["nbf"]
213
+
214
+ payload
215
+ rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
216
+ nil
217
+ end
154
218
  end
155
219
 
156
220
  # ── Token API (auto-selects HS256 or RS256) ─────────────────
157
221
 
158
- def get_token(payload, expires_in: 60, secret: nil)
222
+ # Mint a signed JWT.
223
+ #
224
+ # `algorithm:` selects the HMAC algorithm (else TINA4_JWT_ALGORITHM, else
225
+ # HS256); an unsupported one raises ArgumentError rather than quietly
226
+ # downgrading. It applies to the HMAC path only — the legacy RS256 path
227
+ # (RSA keys present in .keys/) is unaffected.
228
+ #
229
+ # BREAKING (deliberate): no "nbf" claim is stamped. It duplicated "iat",
230
+ # added no security, and created clock-skew rejections; RFC 7519 nbf is for
231
+ # deliberately post-dated tokens, which stays fully supported when the caller
232
+ # passes its own "nbf" in the payload. Python/PHP/Node never auto-stamped it,
233
+ # so Ruby doing so was the parity break.
234
+ def get_token(payload, expires_in: 60, secret: nil, algorithm: nil)
159
235
  now = Time.now.to_i
160
236
  claims = payload.merge(
161
237
  "iat" => now,
162
- "exp" => now + (expires_in * 60).to_i,
163
- "nbf" => now
238
+ "exp" => now + (expires_in * 60).to_i
164
239
  )
165
240
 
166
241
  if secret
167
- hmac_encode(claims, secret)
242
+ hmac_encode(claims, secret, algorithm: algorithm)
168
243
  elsif use_hmac?
169
- hmac_encode(claims, hmac_secret)
244
+ hmac_encode(claims, hmac_secret, algorithm: algorithm)
170
245
  else
171
246
  ensure_keys
172
247
  require "jwt"
@@ -247,16 +322,28 @@ module Tina4
247
322
  nil
248
323
  end
249
324
 
325
+ # Validate and re-issue a token with the same claims.
326
+ #
327
+ # Only "iat"/"exp" are dropped (they are re-stamped). A caller-supplied "nbf"
328
+ # is PRESERVED — matching the Python master, and keeping the promise made
329
+ # when auto-nbf was removed: a deliberately post-dated claim is the issuer's,
330
+ # and a refresh must not quietly erase it.
250
331
  def refresh_token(token, expires_in: 60)
251
332
  return nil unless valid_token(token)
252
333
 
253
334
  payload = get_payload(token)
254
335
  return nil unless payload
255
- payload = payload.reject { |k, _| %w[iat exp nbf].include?(k) }
336
+ payload = payload.reject { |k, _| %w[iat exp].include?(k) }
256
337
  get_token(payload, expires_in: expires_in)
257
338
  end
258
339
 
259
- def authenticate_request(headers, secret: nil, algorithm: "HS256")
340
+ # Extract and validate auth from request headers.
341
+ #
342
+ # `secret:` and `algorithm:` are real overrides. `algorithm:` used to be
343
+ # accepted with a hardcoded "HS256" default and then dropped on the floor, so
344
+ # an explicit argument silently lost to TINA4_JWT_ALGORITHM — the precedence
345
+ # is now honoured here too (explicit > env > HS256).
346
+ def authenticate_request(headers, secret: nil, algorithm: nil)
260
347
  auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
261
348
  return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
262
349
 
@@ -271,9 +358,10 @@ module Tina4
271
358
  return { "api_key" => true }
272
359
  end
273
360
 
274
- # If a custom secret is provided, validate against it directly
275
- if secret
276
- payload = hmac_decode(token, secret)
361
+ # If a custom secret and/or algorithm is provided, validate against those
362
+ # directly rather than this process's env-resolved defaults.
363
+ if secret || algorithm
364
+ payload = hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
277
365
  return payload ? payload : nil
278
366
  end
279
367
 
data/lib/tina4/cli.rb CHANGED
@@ -467,12 +467,21 @@ module Tina4
467
467
  # ── start ─────────────────────────────────────────────────────────────
468
468
 
469
469
  def cmd_start(argv)
470
- options = { port: nil, host: nil, dev: false, no_browser: false, no_reload: false, production: false }
470
+ options = { port: nil, host: nil, dev: false, no_browser: false, no_reload: false, production: false,
471
+ managed: false }
471
472
  parser = OptionParser.new do |opts|
472
473
  opts.banner = "Usage: tina4ruby start [options]"
473
474
  opts.on("-p", "--port PORT", Integer, "Port (default: 7147)") { |v| options[:port] = v }
474
475
  opts.on("-h", "--host HOST", "Host (default: 0.0.0.0)") { |v| options[:host] = v }
475
476
  opts.on("-d", "--dev", "Enable dev mode with auto-reload") { options[:dev] = true }
477
+ # --managed says the Rust CLI owns this process (it supervises, watches
478
+ # files, and compiles SCSS, so the framework must not duplicate any of
479
+ # it). "managed" was already declared in boolean_flags above, but this
480
+ # parser never accepted it, so `tina4ruby serve --managed` died with
481
+ # OptionParser::InvalidOption -- which is precisely how the Rust CLI
482
+ # invokes PHP. That mismatch is why Ruby could not be launched through
483
+ # the shared launcher the other frameworks use.
484
+ opts.on("--managed", "Running under the tina4 CLI supervisor") { options[:managed] = true }
476
485
  opts.on("--production", "Use production server (Puma)") { options[:production] = true }
477
486
  opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
478
487
  opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
@@ -502,12 +511,10 @@ module Tina4
502
511
  root_dir = Dir.pwd
503
512
  Tina4.initialize!(root_dir)
504
513
 
505
- # Register health check endpoint
506
- Tina4::Health.register!
507
-
508
- # Register the always-on Frond {% live %} refresh endpoint
509
- # (GET /__frond/live/{name}) so server-rendered live blocks can poll/SSE.
510
- Tina4::Frond.register_live_endpoint!
514
+ # Built-in routes (health, Frond live). Shared with Tina4.run! so the two
515
+ # entry points cannot drift again -- they did, and app.rb served 404 on
516
+ # /health for it. register! is idempotent, so calling it here is safe.
517
+ Tina4.register_builtin_routes!
511
518
 
512
519
  # Load route files
513
520
  load_routes(root_dir)
data/lib/tina4/health.rb CHANGED
@@ -16,12 +16,22 @@ module Tina4
16
16
  end
17
17
 
18
18
  def register!
19
+ # Idempotent by ASKING THE ROUTER, not by remembering. run! and the CLI
20
+ # both call this, and it can run more than once per process. A boolean
21
+ # flag looked equivalent and was not: Router.clear! (every spec, and
22
+ # any re-init) wipes the routes while the flag stays true, so /health
23
+ # silently vanishes for the rest of the run. Querying the router is
24
+ # self-healing -- cleared routes re-register, present ones don't
25
+ # duplicate.
26
+ return if Tina4::Router.find_route("/health", "GET")
27
+
19
28
  # Register at the configured path. The legacy "/health" path stays
20
29
  # registered for backward-compat.
21
30
  Tina4::Router.add("GET", path, method(:handle))
22
31
  Tina4::Router.add("GET", "/health", method(:handle)) unless path == "/health"
23
32
  end
24
33
 
34
+
25
35
  def handle(_request, response)
26
36
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
37
  uptime = (now - START_TIME).round(2)
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.91"
4
+ VERSION = "3.13.93"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -309,6 +309,14 @@ module Tina4
309
309
  end
310
310
  end
311
311
 
312
+ # The framework's own routes, in ONE place. Both entry points call this:
313
+ # Tina4.run! (app.rb) and the CLI's cmd_start. Anything added here is
314
+ # available however the app was launched -- which is the whole point.
315
+ def register_builtin_routes!
316
+ Tina4::Health.register!
317
+ Tina4::Frond.register_live_endpoint!
318
+ end
319
+
312
320
  def run!(root_dir = nil, port: nil, host: nil, debug: nil)
313
321
  # Handle legacy call: run!(port: 7147) where root_dir receives the hash
314
322
  if root_dir.is_a?(Hash)
@@ -325,6 +333,14 @@ module Tina4
325
333
 
326
334
  initialize!(root_dir) unless @root_dir
327
335
 
336
+ # Built-in routes. These used to be registered ONLY by the CLI's
337
+ # cmd_start, so an app booted the documented way -- `Tina4.run!` from
338
+ # app.rb, which is exactly what `tina4 init ruby` scaffolds -- served 404
339
+ # on /health while the identical code under `tina4ruby serve` served 200.
340
+ # A container health check pointed at /health therefore failed against a
341
+ # perfectly healthy app.
342
+ register_builtin_routes!
343
+
328
344
  host = ENV.fetch("HOST", ENV.fetch("TINA4_HOST", "0.0.0.0"))
329
345
  port = ENV.fetch("PORT", ENV.fetch("TINA4_PORT", "7147")).to_i
330
346
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.91
4
+ version: 3.13.93
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team