tina4ruby 3.13.36 → 3.13.38

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: 7266dfb9cb9605c62b61d64de0c2586ff6824f2a22ad87aae74403f1d171c495
4
- data.tar.gz: 0f9dffee4f4242c9c03899b79742a314bde065308da3e852c1af76fb1ade6be9
3
+ metadata.gz: 6ed3d68931d97c88f7d7ea9128420ce4b531da04f47536c3f64abac71f3edbb3
4
+ data.tar.gz: 4124c02d8ddefcb645eee1d5a52ddef31a4e2ebeecfd11fe81b7999a3784802a
5
5
  SHA512:
6
- metadata.gz: ad75e74701786bf08fd4fc3c143a0d5f6731dbe0aa5feb5d46afb5c2472a113daa9dd0d2962f616c0a811318fed23091e40248117aee22923ea7fa35a81b6ab5
7
- data.tar.gz: c5622aff3bf9fb522969dbe5431ae1a2aa8416712ade87ccd277529418a1aea6a427a25a211198342cfca0bebcff81305db70e6be01a0a11cfd7a2e99ebd6a9a
6
+ metadata.gz: 6a5f5f009385d5e4ce97d0289db83d2d60d1bb2410019c88892fe5f6c22fc2c358654021965ebe4a8b2d159ac7958dd6b57ba41c8b879cf385cc6632428666c5
7
+ data.tar.gz: 4a7d3a41ce8ca5e8283318d487745b90c726e03c648c4480807c8e2fd40910d7472e5371e0c910ef6e68e914d057d65faaa4955efaf585bc93d84919ac567de6
data/lib/tina4/auth.rb CHANGED
@@ -3,11 +3,20 @@ require "openssl"
3
3
  require "base64"
4
4
  require "json"
5
5
  require "fileutils"
6
+ require "securerandom"
6
7
 
7
8
  module Tina4
8
9
  module Auth
9
10
  KEYS_DIR = ".keys"
10
11
 
12
+ # Single source of truth for the blank-secret warning, emitted identically
13
+ # from both the CI/prod boot path (ensure_dev_secret) and the lazy
14
+ # per-call resolver (hmac_secret). Actionable: names exactly what to set.
15
+ BLANK_SECRET_WARNING =
16
+ "Auth: TINA4_SECRET is not set — JWT signing is insecure. Set TINA4_SECRET " \
17
+ "to a random value (e.g. `openssl rand -hex 32`) in your environment or " \
18
+ ".env before serving traffic."
19
+
11
20
  class << self
12
21
  def setup(root_dir = Dir.pwd)
13
22
  @keys_dir = File.join(root_dir, KEYS_DIR)
@@ -15,6 +24,54 @@ module Tina4
15
24
  ensure_keys
16
25
  end
17
26
 
27
+ # Boot-time bootstrap (run once after env load, before auth is used).
28
+ #
29
+ # Mirrors the Python master's tina4_python.auth.ensure_dev_secret:
30
+ # - If TINA4_SECRET is already set → no-op (returns nil).
31
+ # - Else if NOT dev, OR CI, OR production → emit the actionable
32
+ # blank-secret warning and return nil. NEVER generates or persists a
33
+ # secret in CI or production. (Hard security constraint.)
34
+ # - Else (dev, not CI, not prod, blank secret) → mint a 32-byte
35
+ # (64 hex char) random secret, set it in the process env immediately,
36
+ # then APPEND it to <root_dir>/.env.local (gitignored, created if
37
+ # missing). On ANY write failure keep the in-memory secret and warn —
38
+ # never raise (boot must not crash).
39
+ #
40
+ # `root_dir` exists only so tests can target a temp dir without chdir;
41
+ # production callers pass nothing (defaults to Dir.pwd).
42
+ #
43
+ # Returns the generated secret (String) when it mints one, else nil.
44
+ def ensure_dev_secret(root_dir = Dir.pwd)
45
+ existing = ENV["TINA4_SECRET"]
46
+ return nil if existing && !existing.empty?
47
+
48
+ unless dev? && !ci? && !production?
49
+ warn_blank_secret
50
+ return nil
51
+ end
52
+
53
+ new_secret = SecureRandom.hex(32) # 32 bytes -> 64 hex chars
54
+ ENV["TINA4_SECRET"] = new_secret # available for this run immediately
55
+
56
+ begin
57
+ local_path = File.join(root_dir, ".env.local")
58
+ # If the file exists and does not end in a newline, prepend one so the
59
+ # new key lands on its own line rather than gluing onto the last value.
60
+ prefix = ""
61
+ if File.exist?(local_path)
62
+ content = File.read(local_path)
63
+ prefix = "\n" if !content.empty? && !content.end_with?("\n")
64
+ end
65
+ File.open(local_path, "a") { |f| f.write("#{prefix}TINA4_SECRET=#{new_secret}\n") }
66
+ log_info("Auth: generated a development secret, saved to .env.local (gitignored)")
67
+ rescue StandardError => e
68
+ # Keep the in-memory secret for this run; just warn. Never crash boot.
69
+ log_warning("Auth: generated a development secret but could not write .env.local (#{e.message}); using it for this run only")
70
+ end
71
+
72
+ new_secret
73
+ end
74
+
18
75
  # ── HS256 helpers (stdlib only, no gem) ──────────────────────
19
76
 
20
77
  # Returns true when SECRET env var is set and no RSA keys exist in .keys/
@@ -28,8 +85,13 @@ module Tina4
28
85
  File.exist?(File.join(@keys_dir, "public.pem")))
29
86
  end
30
87
 
88
+ # Lazy per-call secret resolver. When the secret is blank, emit the
89
+ # actionable blank-secret warning (the same text the CI/prod bootstrap
90
+ # path uses) before returning. Parity with Python's _resolve_secret.
31
91
  def hmac_secret
32
- ENV["TINA4_SECRET"]
92
+ secret = ENV["TINA4_SECRET"]
93
+ warn_blank_secret if secret.nil? || secret.empty?
94
+ secret
33
95
  end
34
96
 
35
97
  # Base64url-encode without padding (JWT spec)
@@ -196,9 +258,12 @@ module Tina4
196
258
 
197
259
  token = Regexp.last_match(1)
198
260
 
199
- # API_KEY bypass — matches tina4_python behavior
200
- api_key = ENV["TINA4_API_KEY"]
201
- if api_key && !api_key.empty? && token == api_key
261
+ # API_KEY bypass — timing-safe comparison via validate_api_key
262
+ # (OpenSSL.fixed_length_secure_compare). Parity with Python's
263
+ # authenticate_request (validate_api_key), PHP (hash_equals) and
264
+ # Node (timingSafeEqual). Never use a plain `==` here — that leaks the
265
+ # key length/prefix through comparison timing.
266
+ if validate_api_key(token)
202
267
  return { "api_key" => true }
203
268
  end
204
269
 
@@ -235,9 +300,12 @@ module Tina4
235
300
 
236
301
  token = Regexp.last_match(1)
237
302
 
238
- # API_KEY bypass — matches tina4_python behavior
239
- api_key = ENV["TINA4_API_KEY"]
240
- if api_key && !api_key.empty? && token == api_key
303
+ # API_KEY bypass — timing-safe comparison via validate_api_key
304
+ # (OpenSSL.fixed_length_secure_compare). Parity with Python's
305
+ # authenticate_request (validate_api_key), PHP (hash_equals) and
306
+ # Node (timingSafeEqual). Never use a plain `==` here — that leaks the
307
+ # key length/prefix through comparison timing.
308
+ if validate_api_key(token)
241
309
  env["tina4.auth"] = { "api_key" => true }
242
310
  return true
243
311
  end
@@ -271,6 +339,49 @@ module Tina4
271
339
 
272
340
  private
273
341
 
342
+ # ── Dev-secret bootstrap helpers (parity with Python master) ──
343
+
344
+ # Dev when the framework debug flag is truthy (TINA4_DEBUG).
345
+ def dev?
346
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
347
+ end
348
+
349
+ # CI when the de-facto CI env var is truthy.
350
+ def ci?
351
+ Tina4::Env.is_truthy(ENV["CI"])
352
+ end
353
+
354
+ # Production when TINA4_ENV (default "development") is "production".
355
+ def production?
356
+ (ENV["TINA4_ENV"] || "development").to_s.strip.downcase == "production"
357
+ end
358
+
359
+ # Emit the single actionable blank-secret warning. Same text from the
360
+ # CI/prod bootstrap path and the lazy per-call resolver.
361
+ def warn_blank_secret
362
+ log_warning(BLANK_SECRET_WARNING)
363
+ end
364
+
365
+ def log_info(message)
366
+ if defined?(Tina4::Log)
367
+ Tina4::Log.info(message)
368
+ else
369
+ warn(message)
370
+ end
371
+ rescue StandardError
372
+ warn(message)
373
+ end
374
+
375
+ def log_warning(message)
376
+ if defined?(Tina4::Log)
377
+ Tina4::Log.warning(message)
378
+ else
379
+ warn(message)
380
+ end
381
+ rescue StandardError
382
+ warn(message)
383
+ end
384
+
274
385
  def ensure_keys
275
386
  @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
276
387
  FileUtils.mkdir_p(@keys_dir)
data/lib/tina4/cli.rb CHANGED
@@ -5,7 +5,7 @@ require "fileutils"
5
5
 
6
6
  module Tina4
7
7
  class CLI
8
- COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
8
+ COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai metrics help].freeze
9
9
 
10
10
  # ── Field type mapping ──────────────────────────────────────────────
11
11
  FIELD_TYPE_MAP = {
@@ -43,6 +43,7 @@ module Tina4
43
43
  when "console" then cmd_console
44
44
  when "generate" then cmd_generate(argv)
45
45
  when "ai" then cmd_ai(argv)
46
+ when "metrics" then cmd_metrics(argv)
46
47
  when "help", "-h", "--help" then cmd_help
47
48
  else
48
49
  puts "Unknown command: #{command}"
@@ -85,7 +86,7 @@ module Tina4
85
86
  # Parse --key value and --flag from args. Returns [flags_hash, positional_array]
86
87
  def parse_flags(args)
87
88
  # Boolean-only flags that never take a value argument
88
- boolean_flags = %w[no-browser no-reload production managed all clear dev]
89
+ boolean_flags = %w[no-browser no-reload production managed all clear dev json]
89
90
 
90
91
  flags = {}
91
92
  positional = []
@@ -488,6 +489,100 @@ module Tina4
488
489
  end
489
490
  end
490
491
 
492
+ # ── metrics ───────────────────────────────────────────────────────────
493
+
494
+ # Report top code-quality offenders (complexity, size, maintainability,
495
+ # tests). Mirrors the Python-master `tina4python metrics` command.
496
+ #
497
+ # tina4ruby metrics # human report, scans src/ (or framework)
498
+ # tina4ruby metrics --top 10 # only the worst 10
499
+ # tina4ruby metrics --path lib # scan a specific directory
500
+ # tina4ruby metrics --json # machine-readable for CI
501
+ # tina4ruby metrics --fail-on warn # exit 1 if any warn/error offender
502
+ # tina4ruby metrics --fail-on error # exit 1 only on error-severity
503
+ def cmd_metrics(argv)
504
+ require "json"
505
+ require "set"
506
+ require_relative "metrics"
507
+
508
+ flags, _positional = parse_flags(argv)
509
+
510
+ top = (flags["top"].to_s =~ /\A\d+\z/) ? flags["top"].to_i : 20
511
+ as_json = flags.key?("json")
512
+ path = flags["path"].is_a?(String) ? flags["path"] : "src"
513
+ fail_on = flags["fail-on"].is_a?(String) ? flags["fail-on"] : nil
514
+
515
+ unless [nil, "warn", "error"].include?(fail_on)
516
+ puts " invalid --fail-on '#{fail_on}' (use warn or error)"
517
+ exit 2
518
+ end
519
+
520
+ result = Tina4::Metrics.offenders(path, top)
521
+ summary = result["summary"]
522
+ found = result["offenders"]
523
+
524
+ if summary.key?("error")
525
+ puts " metrics error: #{summary['error']}"
526
+ exit 2
527
+ end
528
+
529
+ # Decide exit code from the FULL offender set, not just the printed top-N.
530
+ # full_analysis is cached, so this reuses the same analysis.
531
+ all_offenders = Tina4::Metrics.offenders(path, [summary["total_offenders"], 1].max)["offenders"]
532
+ severities = all_offenders.map { |o| o["severity"] }.to_set
533
+ exit_code = 0
534
+ if fail_on == "warn" && !(severities & %w[warn error]).empty?
535
+ exit_code = 1
536
+ elsif fail_on == "error" && severities.include?("error")
537
+ exit_code = 1
538
+ end
539
+
540
+ if as_json
541
+ puts JSON.pretty_generate({ "summary" => summary, "offenders" => found })
542
+ exit exit_code
543
+ end
544
+
545
+ # ── Human report ──────────────────────────────────────────────────
546
+ use_color = $stdout.tty?
547
+ colorize = lambda do |text, code|
548
+ use_color ? "\e[#{code}m#{text}\e[0m" : text
549
+ end
550
+ sev_color = { "error" => "31", "warn" => "33", "info" => "2" } # red / yellow / dim
551
+
552
+ puts
553
+ puts " Tina4 Metrics — #{summary['scan_mode']} scan (#{summary['scan_root']})"
554
+ puts " files: #{summary['files_analyzed']} " \
555
+ "functions: #{summary['total_functions']} " \
556
+ "avg complexity: #{summary['avg_complexity']} " \
557
+ "avg maintainability: #{summary['avg_maintainability']}"
558
+ showing = found.empty? ? "" : " (showing top #{found.length})"
559
+ puts " offenders: #{summary['total_offenders']} total#{showing}"
560
+ puts
561
+
562
+ if found.empty?
563
+ puts " " + colorize.call("✓ no offenders — clean", "32")
564
+ puts
565
+ exit exit_code
566
+ end
567
+
568
+ # Compute column widths so the table lines up.
569
+ locs = found.map { |o| "#{o['file']}:#{o['line']}" }
570
+ loc_w = [("FILE:LINE".length)].concat(locs.map(&:length)).max
571
+ kind_w = [("KIND".length)].concat(found.map { |o| o["kind"].length }).max
572
+
573
+ header = format(" %3s %-8s %-#{kind_w}s %-#{loc_w}s DETAIL", "#", "SEVERITY", "KIND", "FILE:LINE")
574
+ puts colorize.call(header, "1")
575
+ puts " " + ("-" * (header.length - 2))
576
+ found.each_with_index do |o, i|
577
+ sev = o["severity"]
578
+ sev_cell = colorize.call(format("%-8s", sev), sev_color[sev])
579
+ puts format(" %3d %s %-#{kind_w}s %-#{loc_w}s %s",
580
+ i + 1, sev_cell, o["kind"], locs[i], o["detail"])
581
+ end
582
+ puts
583
+ exit exit_code
584
+ end
585
+
491
586
  # ── generate ────────────────────────────────────────────────────────
492
587
 
493
588
  def cmd_generate(argv)
@@ -1225,6 +1320,7 @@ module Tina4
1225
1320
  routes List all registered routes
1226
1321
  console Start an interactive console
1227
1322
  ai Detect AI tools and install context files
1323
+ metrics Rank top code-quality offenders
1228
1324
  help Show this help message
1229
1325
 
1230
1326
  Generators:
@@ -1238,6 +1334,13 @@ module Tina4
1238
1334
  generate view <Name> [--fields "..."] List + detail templates for viewing records
1239
1335
  generate auth Login/register/logout routes + User model + templates
1240
1336
 
1337
+ Metrics:
1338
+ metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]
1339
+ --top N Show only the worst N offenders (default: 20)
1340
+ --json Print machine-readable JSON ({summary, offenders}) for CI
1341
+ --fail-on Exit 1 if any offender at/above this severity (warn|error)
1342
+ --path DIR Scan DIR (default: src/, auto-resolves to the framework)
1343
+
1241
1344
  Field types: string, int, float, bool, text, datetime, blob
1242
1345
  Table names: singular by default (Product -> product)
1243
1346
 
@@ -1346,6 +1449,7 @@ module Tina4
1346
1449
  unless File.exist?(File.join(dir, ".gitignore"))
1347
1450
  File.write(File.join(dir, ".gitignore"), <<~TEXT)
1348
1451
  .env
1452
+ .env.local
1349
1453
  .keys/
1350
1454
  logs/
1351
1455
  sessions/