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 +4 -4
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +106 -2
- data/lib/tina4/database.rb +356 -46
- data/lib/tina4/dev_admin.rb +54 -11
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/mcp.rb +10 -3
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +238 -47
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +6 -4
- data/lib/tina4/orm.rb +13 -10
- data/lib/tina4/public/js/tina4-dev-admin.js +212 -212
- data/lib/tina4/public/js/tina4-dev-admin.min.js +212 -212
- data/lib/tina4/rack_app.rb +17 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +354 -18
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +11 -9
- metadata +6 -47
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ed3d68931d97c88f7d7ea9128420ce4b531da04f47536c3f64abac71f3edbb3
|
|
4
|
+
data.tar.gz: 4124c02d8ddefcb645eee1d5a52ddef31a4e2ebeecfd11fe81b7999a3784802a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 —
|
|
200
|
-
|
|
201
|
-
|
|
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 —
|
|
239
|
-
|
|
240
|
-
|
|
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/
|