tina4ruby 3.13.138 → 3.13.140

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: 1d0175b940e39eb271fcdda84cecc6893217d9d660febdcd0afc2f96d8c38dfe
4
- data.tar.gz: b44423de677c9523749b49db4814e156bc621090167a8870d0335ca46cef7af3
3
+ metadata.gz: 68bda3e81c456588a9a4db6501cb402b1eaa570d5f2bfbee0efb83bd9cb61beb
4
+ data.tar.gz: 61271a97c34bf49f68fcaf605d955d6e4f932f72fa370c884f4f0e91ef432e91
5
5
  SHA512:
6
- metadata.gz: eb1cf1ca28e038b3d3704fd41bfad3a3e176a114e0b82875ae26af0d0a15e7f8117003c6d0f48c5f44c87a3096eaa27f6683f944df80c04845536ee431ed782d
7
- data.tar.gz: e48d0a97a012fbc94b256dbddbf1fd911b39dfdff548e63516d9daf3f2b6aad2028e8706fe3da9c2f522aca427ab8440f5c24689d3f448161ea24f748fb91cc3
6
+ metadata.gz: 847b4f0439265406da7152292469d91b081a04e468f177b78980c9b669c96e60cca17ac06820bbdb5c66034802145ff64b294c7949136e2392236575170aeb3e
7
+ data.tar.gz: f690d2726ce41de740e44b6db7cc0cd3104ac40cace6c30dc5a4a94eef832f7e09128608b9cfaebf6e167687403917ca14c156d5f4b94bd65b8c46930d191bce
data/CHANGELOG.md CHANGED
@@ -6,6 +6,29 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.140 — 2026-09-28
10
+
11
+ A housekeeping release that tightens the guards and dedups the SQLite plumbing. The CLAUDE.md doc-drift gate now watches this repo, so a doc that names a Tina4 method the code no longer has fails loudly instead of drifting on quietly. The SQLite path resolver learnt some manners: an escaping relative path is refused, and it creates the parent folders itself at 0775 (ADR-0086). The framework still carries no required runtime dependencies.
12
+
13
+ ### Fixes
14
+ - SQLite refuses a relative path that escapes the project and creates its parent directories at 0775, hardening ADR-0086.
15
+ - The maintainer skills stale check resolves correctly and warns when an addGlobal closure would go stale (skill bugs 90/91).
16
+
17
+ ### Refactors
18
+ - The SQLite `resolve_path` logic moves into one shared `SqlitePathResolver` module, so every caller reads the same path the same way.
19
+ - `symbolize_rows` and `symbolize_keys` collapse into one shared module, characterised first so the behaviour cannot shift.
20
+
21
+ ### Docs
22
+ - The Web Push skill mirrors sync back to the `.claude` canonical, so the two copies agree.
23
+
24
+ ### CI
25
+ - A CLAUDE.md doc-drift gate lands, the tina4-js rtc.md mirror syncs, and the SQLTranslator doc drift is fixed.
26
+ - The CLI crud generator locks in single pluralisation, so an `Order` model becomes `orders`, never `orderss`.
27
+
28
+ ## 3.13.139
29
+
30
+ Outbound requests from the Api client and Web Push are guarded against SSRF: private, loopback and link-local targets are refused unless explicitly allowed (ADR-0084). The development admin surface is hardened -- the health endpoint no longer discloses the framework version outside debug, and development binds loopback by default (ADR-0078). Production refuses to boot with a blank or short HMAC secret so tokens cannot be forged (ADR-0079). Queue reject() dead-letters immediately and size("dead") reports the dead-letter depth (ADR-0022, ADR-0023). Session table first-use is concurrency-safe across every engine. Mail encryption follows the configured TLS/STARTTLS contract (ADR-0071). Log output, driver-install hints and WSDL/mail diagnostics redact credentials and URLs. Publishing is gated behind a protected environment with release actions pinned by commit SHA, and a medium-severity API token-leak path is closed. The framework still has no required runtime dependencies.
31
+
9
32
  ## 3.13.138
10
33
 
11
34
  HTTP clients strip configured-origin Authorization and Cookie credentials from off-origin initial targets across normal, upload, download and streaming paths. Forwarded Host and Proto require a trusted raw transport peer. Every development HTTP method uses the peer, Host and Origin boundary; resolved file paths and database identifiers are confined. Remote non-MCP development routes require the dedicated token while the accepted MCP transport API_KEY fallback remains supported. Development file readers validate and read one descriptor, preserving public in-project symlinks and existing size behavior. Database, MQTT and malformed Redis backplane diagnostics redact connection credentials; raw file responses preserve exact binary bytes.
data/lib/tina4/api.rb CHANGED
@@ -13,6 +13,7 @@ require "json"
13
13
  require_relative "base64"
14
14
  require "securerandom"
15
15
  require_relative "parse_json"
16
+ require_relative "ssrf"
16
17
 
17
18
  module Tina4
18
19
  # Statuses that warrant an automatic retry when max_retries > 0: rate-limit
@@ -108,7 +109,7 @@ module Tina4
108
109
  def initialize(base_url, headers: {}, timeout: 30,
109
110
  bearer_token: nil, username: nil, password: nil,
110
111
  verify_ssl: nil, max_retries: 0, retry_backoff: 0.5,
111
- transport: nil, cookies: false)
112
+ transport: nil, cookies: false, allow_hosts: nil)
112
113
  if !transport.nil? && !transport.respond_to?(:call)
113
114
  raise ArgumentError, "transport must respond to #call(method, url, headers, body, timeout)"
114
115
  end
@@ -130,6 +131,8 @@ module Tina4
130
131
  @transport = transport
131
132
  @cookies_enabled = cookies ? true : false
132
133
  @cookies = {}
134
+ # SSRF guard (ADR-0084): explicit allow-list of hosts / host:port / CIDRs.
135
+ @allow_hosts = allow_hosts || []
133
136
 
134
137
  # Bearer wins over basic-auth when both passed
135
138
  if bearer_token
@@ -455,6 +458,13 @@ module Tina4
455
458
  when "HEAD" then Net::HTTP::Head
456
459
  else raise ArgumentError, "unsupported stream method: #{method}"
457
460
  end
461
+ # SSRF guard (ADR-0084): refuse a private/internal target before connect.
462
+ begin
463
+ Tina4::Ssrf.guard_url!(uri, @allow_hosts)
464
+ rescue Tina4::SsrfError => e
465
+ raise APIStreamError.new(e.message)
466
+ end
467
+
458
468
  request = request_class.new(uri)
459
469
  apply_headers(request, headers || {})
460
470
  cookie = cookie_header
@@ -596,6 +606,12 @@ module Tina4
596
606
  current_body = body
597
607
 
598
608
  loop do
609
+ # SSRF guard (ADR-0084): re-validated per hop, so a redirect to a
610
+ # private/internal address is refused even when the initial URL was
611
+ # public. Raises SsrfError; attempt_request/download turn it into a
612
+ # status-0 error response.
613
+ Tina4::Ssrf.guard_url!(current_uri, @allow_hosts)
614
+
599
615
  http = Net::HTTP.new(current_uri.host, current_uri.port)
600
616
  http.use_ssl = current_uri.scheme == "https"
601
617
  # Only disable verification when EXPLICITLY false — nil/true keep the
data/lib/tina4/auth.rb CHANGED
@@ -68,13 +68,72 @@ module Tina4
68
68
  # rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
69
69
  JWT_LEEWAY_SECONDS = 60
70
70
 
71
+ # Token purposes that are never an identity (ADR-0079 s1). Frond's
72
+ # form_token signs a JWT with the same TINA4_SECRET as an auth token, marked
73
+ # "type" => "form". A form token proves where a write came from, never who
74
+ # the caller is, so every identity gate refuses these purposes. valid_token
75
+ # itself stays purpose-neutral: CsrfMiddleware needs it for form tokens.
76
+ NON_IDENTITY_TOKEN_TYPES = %w[form].freeze
77
+
78
+ # Minimum HMAC key length in bytes (ADR-0079 s2): the HS256 output size,
79
+ # RFC 7518 s3.2. A blank key is the shortest case - anyone can reproduce an
80
+ # HMAC made with it.
81
+ MIN_SECRET_BYTES = 32
82
+
83
+ # The HMAC signing key is blank or shorter than MIN_SECRET_BYTES. An
84
+ # ArgumentError, Ruby's equivalent of Python's ValueError.
85
+ class InsecureSecretError < ArgumentError; end
86
+
71
87
  class << self
88
+ # Explicitly provision an RS256 key pair in <root_dir>/.keys (opt-in). The
89
+ # framework never calls this on its own: a blank TINA4_SECRET used to mint
90
+ # a key pair implicitly at boot (ADR-0079 s2).
72
91
  def setup(root_dir = Dir.pwd)
73
92
  @keys_dir = File.join(root_dir, KEYS_DIR)
74
93
  FileUtils.mkdir_p(@keys_dir)
75
94
  ensure_keys
76
95
  end
77
96
 
97
+ # Point RS256 at <root_dir>/.keys WITHOUT creating anything. Boot uses
98
+ # this, so an operator-provisioned key pair there keeps working.
99
+ def keys_root=(root_dir)
100
+ @keys_dir = File.join(root_dir, KEYS_DIR)
101
+ @private_key = nil
102
+ @public_key = nil
103
+ end
104
+
105
+ # True when a VERIFIED payload may stand for a caller's identity. A payload
106
+ # whose "type" is a reserved non-identity purpose (a Frond form token) is
107
+ # refused; application payloads are otherwise untouched.
108
+ def identity_payload?(payload)
109
+ payload.is_a?(Hash) && !NON_IDENTITY_TOKEN_TYPES.include?(payload["type"])
110
+ end
111
+
112
+ # The actionable error for a weak HMAC key, or nil when the key is usable.
113
+ def insecure_secret_message(secret)
114
+ length = secret.to_s.bytesize
115
+ return nil if length >= MIN_SECRET_BYTES
116
+
117
+ what = length.zero? ? "is not set" : "is #{length} bytes"
118
+ "Auth: TINA4_SECRET #{what}; an HMAC JWT secret must be at least " \
119
+ "#{MIN_SECRET_BYTES} bytes. Generate one with `openssl rand -hex 32` and set " \
120
+ "TINA4_SECRET in your environment or .env."
121
+ end
122
+
123
+ # Refuse to boot with a secret that makes tokens forgeable (ADR-0079 s2).
124
+ # Outside dev a blank TINA4_SECRET is refused; in any mode a set-but-short
125
+ # one is refused. An operator-provisioned RS256 key pair in .keys/ stands
126
+ # in for the secret. Raises InsecureSecretError with the actionable message.
127
+ def require_boot_secret!
128
+ return if rsa_keys_present?
129
+
130
+ secret = ENV["TINA4_SECRET"].to_s
131
+ return if secret.empty? && dev?
132
+
133
+ message = insecure_secret_message(secret)
134
+ raise InsecureSecretError, message if message
135
+ end
136
+
78
137
  # Boot-time bootstrap (run once after env load, before auth is used).
79
138
  #
80
139
  # Mirrors the Python master's tina4_python.auth.ensure_dev_secret:
@@ -123,15 +182,17 @@ module Tina4
123
182
 
124
183
  # ── HS256 helpers (stdlib only, no gem) ──────────────────────
125
184
 
126
- # Returns true when SECRET env var is set and no RSA keys exist in .keys/
185
+ # HMAC with TINA4_SECRET unless an RS256 key pair has been provisioned in
186
+ # .keys/ (kept for existing deployments). A blank secret no longer falls
187
+ # back to an implicitly generated key pair: it is refused (ADR-0079 s2).
127
188
  def use_hmac?
128
- secret = ENV["TINA4_SECRET"]
129
- return false if secret.nil? || secret.empty?
189
+ !rsa_keys_present?
190
+ end
130
191
 
131
- # If RSA keys already exist on disk, prefer RS256 for backward compat
192
+ def rsa_keys_present?
132
193
  @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
133
- !(File.exist?(File.join(@keys_dir, "private.pem")) &&
134
- File.exist?(File.join(@keys_dir, "public.pem")))
194
+ File.exist?(File.join(@keys_dir, "private.pem")) &&
195
+ File.exist?(File.join(@keys_dir, "public.pem"))
135
196
  end
136
197
 
137
198
  # Lazy per-call secret resolver. When the secret is blank, emit the
@@ -180,6 +241,9 @@ module Tina4
180
241
  # HMAC the signing input with the digest the algorithm actually names, so the
181
242
  # header's "alg" can never disagree with the bytes we produced.
182
243
  def hmac_signature(algorithm, secret, signing_input)
244
+ weak = insecure_secret_message(secret)
245
+ raise InsecureSecretError, weak if weak
246
+
183
247
  OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input)
184
248
  end
185
249
 
@@ -253,6 +317,15 @@ module Tina4
253
317
  # configuration error that must surface, not become a nil "invalid token".
254
318
  alg = resolve_algorithm(algorithm)
255
319
 
320
+ # A blank or short key verifies tokens anyone can mint, so the token is
321
+ # REJECTED (fail closed with a 401, never a 500) and the operator is
322
+ # told why. ADR-0079 s2.
323
+ weak = insecure_secret_message(secret)
324
+ if weak
325
+ log_warning(weak)
326
+ return nil
327
+ end
328
+
256
329
  decode_envelope(token, alg) do |input, signature|
257
330
  expected = hmac_signature(alg, secret, input)
258
331
  # Constant-time comparison to prevent timing attacks. Lengths must match
@@ -310,7 +383,6 @@ module Tina4
310
383
  elsif use_hmac?
311
384
  hmac_encode(claims, hmac_secret, algorithm: algorithm)
312
385
  else
313
- ensure_keys
314
386
  rs256_encode(claims)
315
387
  end
316
388
  end
@@ -327,7 +399,6 @@ module Tina4
327
399
  if use_hmac?
328
400
  hmac_decode(token, hmac_secret) # returns Hash payload or nil
329
401
  else
330
- ensure_keys
331
402
  rs256_decode(token)
332
403
  end
333
404
  end
@@ -407,10 +478,11 @@ module Tina4
407
478
  # than this process's env-resolved defaults.
408
479
  payload = if secret || algorithm
409
480
  hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
410
- elsif valid_token(token)
411
- get_payload(token)
481
+ else
482
+ valid_token(token)
412
483
  end
413
- return payload if payload
484
+ # A form token is not an identity (ADR-0079 s1).
485
+ return identity_payload?(payload) ? payload : nil if payload
414
486
 
415
487
  # API_KEY bypass — timing-safe comparison via validate_api_key
416
488
  # (OpenSSL.fixed_length_secure_compare). Parity with Python's
@@ -451,9 +523,13 @@ module Tina4
451
523
  token = Regexp.last_match(1)
452
524
 
453
525
  # JWT first, then the API key — same order and same payload shape as
454
- # authenticate_request above (and as Python/PHP/Node).
455
- if valid_token(token)
456
- env["tina4.auth"] = get_payload(token)
526
+ # authenticate_request above (and as Python/PHP/Node). A form token is
527
+ # not an identity (ADR-0079 s1).
528
+ payload = valid_token(token)
529
+ if payload
530
+ return false unless identity_payload?(payload)
531
+
532
+ env["tina4.auth"] = payload
457
533
  return true
458
534
  end
459
535
 
@@ -78,6 +78,14 @@ module Tina4
78
78
  def available?
79
79
  true
80
80
  end
81
+
82
+ # When available? is false because a required gem is not installed, the
83
+ # sentence that names the gem and the install command; nil otherwise.
84
+ #
85
+ # @return [String, nil]
86
+ def missing_driver_message
87
+ nil
88
+ end
81
89
  end
82
90
  end
83
91
  end
@@ -23,6 +23,7 @@ module Tina4
23
23
  @hits = 0
24
24
  @misses = 0
25
25
  @coll = nil
26
+ @driver_missing = false
26
27
  begin
27
28
  require "mongo"
28
29
  Mongo::Logger.logger.level = Logger::FATAL if defined?(Mongo::Logger)
@@ -39,7 +40,12 @@ module Tina4
39
40
  @coll = client[:tina4_cache]
40
41
  @coll.indexes.create_one({ expires_at: 1 }, expire_after: 0)
41
42
  client.database.command(ping: 1)
42
- rescue LoadError, StandardError
43
+ rescue LoadError
44
+ # Recorded apart from a service failure so the factory's fallback
45
+ # warning names the gem only when the gem is really what is missing.
46
+ @driver_missing = true
47
+ @coll = nil
48
+ rescue StandardError
43
49
  @coll = nil
44
50
  end
45
51
  end
@@ -48,6 +54,15 @@ module Tina4
48
54
  !@coll.nil?
49
55
  end
50
56
 
57
+ # The install hint for the factory's fallback warning, or nil when the
58
+ # gem loaded and the service is what failed.
59
+ def missing_driver_message
60
+ return nil unless @driver_missing
61
+
62
+ "The 'mongo' gem is required for the mongodb cache backend. Install it with: " \
63
+ "bundle add mongo (or add gem \"mongo\" to your Gemfile)"
64
+ end
65
+
51
66
  def get(key)
52
67
  if @coll.nil?
53
68
  @misses += 1
@@ -92,9 +92,11 @@ module Tina4
92
92
 
93
93
  # Configured backend unusable — degrade to the file backend.
94
94
  begin
95
+ install_hint = be.missing_driver_message
95
96
  Tina4::Log.warning(
96
97
  "Cache backend '#{backend}' is unavailable " \
97
- "(driver missing or service unreachable) — falling back to 'file'."
98
+ "(driver missing or service unreachable) — falling back to 'file'." \
99
+ "#{install_hint ? " #{install_hint}" : ""}"
98
100
  )
99
101
  rescue StandardError
100
102
  # Logging must never break cache construction.
data/lib/tina4/cli.rb CHANGED
@@ -715,6 +715,11 @@ module Tina4
715
715
 
716
716
  require_relative "../tina4"
717
717
 
718
+ # --production turns debug off: the explicit flag beats .env and the
719
+ # environment (ADR-0041, ADR-0079 s3). Set before the env files load,
720
+ # which are first-wins. It used to only choose Puma.
721
+ ENV["TINA4_DEBUG"] = "false" if options[:production]
722
+
718
723
  root_dir = Dir.pwd
719
724
  Tina4.initialize!(root_dir)
720
725
 
@@ -7,10 +7,16 @@
7
7
 
8
8
 
9
9
  require_relative "../sqlite3_gem"
10
+ require_relative "../sqlite_path_resolver"
11
+ require_relative "../row_symbolizer"
10
12
 
11
13
  module Tina4
12
14
  module Adapters
13
15
  class Sqlite3Adapter
16
+ # Shared SQLite path resolution — one home for both SQLite resolvers.
17
+ extend Tina4::SqlitePathResolver
18
+ include Tina4::RowSymbolizer
19
+
14
20
  attr_reader :connection, :db_path
15
21
 
16
22
  def initialize(connection_string = nil)
@@ -30,32 +36,6 @@ module Tina4
30
36
  self
31
37
  end
32
38
 
33
- # Resolve a SQLite URL / path against the project root (cwd).
34
- # Convention matches Tina4::Drivers::SqliteDriver.resolve_path —
35
- # three slashes = relative, four = absolute, drive letter = Windows abs.
36
- def self.resolve_path(connection_string)
37
- return ":memory:" if connection_string == "sqlite::memory:" || connection_string == "sqlite:///:memory:"
38
-
39
- raw = connection_string.to_s
40
- .sub(/^sqlite3?:\/\/\//, "")
41
- .sub(/^sqlite3?:\/\//, "")
42
- .sub(/^sqlite3?:/, "")
43
- return ":memory:" if raw == ":memory:"
44
-
45
- is_windows_abs = raw.match?(/^[A-Za-z]:[\/\\]/)
46
- is_unix_abs = raw.start_with?("/")
47
-
48
- if is_windows_abs || is_unix_abs
49
- raw
50
- else
51
- resolved = File.join(Dir.pwd, raw)
52
- parent = File.dirname(resolved)
53
- require "fileutils"
54
- FileUtils.mkdir_p(parent) unless File.directory?(parent)
55
- resolved
56
- end
57
- end
58
-
59
39
  def close
60
40
  @connection.close if @connection
61
41
  @connection = nil
@@ -163,34 +143,6 @@ module Tina4
163
143
  def apply_limit(sql, limit, offset = 0)
164
144
  "#{sql}\nLIMIT #{limit} OFFSET #{offset}"
165
145
  end
166
-
167
- private
168
-
169
- # Symbolize a whole result set's keys, computing the mapping ONCE per query
170
- # rather than per cell. See the matching helper in drivers/sqlite_driver.rb
171
- # for the rationale: the per-row form ran k.to_s.to_sym for every cell, so a
172
- # 5,000-row x 6-column fetch did 30,000 conversions for 6 distinct keys. The
173
- # is_a? guard still drops any positional Integer keys older gems emit.
174
- def symbolize_rows(rows)
175
- return rows if rows.empty?
176
-
177
- str_keys = rows.first.keys.select { |k| k.is_a?(String) || k.is_a?(Symbol) }
178
- sym_keys = str_keys.map(&:to_sym)
179
- count = str_keys.length
180
- rows.map do |row|
181
- out = {}
182
- i = 0
183
- while i < count
184
- out[sym_keys[i]] = row[str_keys[i]]
185
- i += 1
186
- end
187
- out
188
- end
189
- end
190
-
191
- def symbolize_keys(hash)
192
- symbolize_rows([hash]).first
193
- end
194
146
  end
195
147
  end
196
148
  end
@@ -822,10 +822,17 @@ module Tina4
822
822
  resolved = begin
823
823
  safe_project_path(file_path, extra_roots: [scan_root])
824
824
  rescue ArgumentError
825
- begin
826
- scan_root ? safe_project_path(File.join(scan_root, file_path), extra_roots: [scan_root]) : nil
827
- rescue ArgumentError
828
- nil
825
+ # A RELATIVE path may be retried against the last scan root; an
826
+ # absolute path that resolved outside the project is simply outside -
827
+ # never re-based under the scan root (File.join would fold it into a
828
+ # bogus in-root path that 404s instead of refusing 403). Parity with
829
+ # Python/Node: outside -> 403.
830
+ if scan_root && !file_path.empty? && !File.absolute_path?(file_path)
831
+ begin
832
+ safe_project_path(File.join(scan_root, file_path), extra_roots: [scan_root])
833
+ rescue ArgumentError
834
+ nil
835
+ end
829
836
  end
830
837
  end
831
838
  return json_response({ "error" => "Path outside project" }, 403) if !file_path.empty? && resolved.nil?
@@ -8,11 +8,17 @@
8
8
 
9
9
  require_relative "schema_split"
10
10
  require_relative "../sqlite3_gem"
11
+ require_relative "../sqlite_path_resolver"
12
+ require_relative "../row_symbolizer"
11
13
 
12
14
  module Tina4
13
15
  module Drivers
14
16
  class SqliteDriver
17
+ # Shared SQLite path resolution — one home for both SQLite resolvers.
18
+ extend Tina4::SqlitePathResolver
19
+
15
20
  include Tina4::DatabaseAdapter
21
+ include Tina4::RowSymbolizer
16
22
  include SchemaSplit
17
23
  attr_reader :connection
18
24
 
@@ -39,54 +45,18 @@ module Tina4
39
45
 
40
46
  @connection = SQLite3::Database.new(db_path)
41
47
  @connection.results_as_hash = true
48
+ # Wait up to 5 s for another process's write lock instead of failing at
49
+ # once with SQLite3::BusyException. The sqlite3 gem's default is NO wait,
50
+ # so two processes writing the same file collided on the first overlap:
51
+ # MEASURED on the lab, five of six processes racing a first session write
52
+ # died with "database is locked". 5000 ms matches PHP's busyTimeout(5000)
53
+ # and Python's sqlite3 default. Set BEFORE the WAL pragma so that write
54
+ # waits too.
55
+ @connection.busy_timeout = 5000
42
56
  @connection.execute("PRAGMA journal_mode=WAL")
43
57
  @connection.execute("PRAGMA foreign_keys=ON")
44
58
  end
45
59
 
46
- # Resolve a SQLite URL / path against the project root (cwd).
47
- #
48
- # Convention (matches tina4-python, tina4-php, tina4-nodejs):
49
- # sqlite::memory: → :memory:
50
- # sqlite:///:memory: → :memory:
51
- # sqlite:///app.db → {cwd}/app.db (relative)
52
- # sqlite:///data/app.db → {cwd}/data/app.db (relative; auto-mkdir under cwd)
53
- # sqlite:////var/data/app.db → /var/data/app.db (absolute; no auto-mkdir)
54
- # sqlite:///C:/Users/app.db → C:/Users/app.db (Windows absolute)
55
- #
56
- # Never mkdir outside cwd — that was the root cause of the
57
- # "Read-only file system: '/data'" crash on macOS.
58
- def self.resolve_path(connection_string)
59
- return ":memory:" if connection_string == "sqlite::memory:" || connection_string == "sqlite:///:memory:"
60
-
61
- # Strip the scheme + up to three slashes, preserving a potential fourth
62
- # slash (absolute) or drive letter.
63
- # `sqlite3:` is a documented alias for `sqlite:`. Normalise it FIRST or
64
- # none of the strips below match and `raw` keeps the whole connection
65
- # string, so the database file is literally named "sqlite3:app.db".
66
- # Not merely ugly: a colon is an illegal filename character on Windows,
67
- # so the documented alias is unusable there. DatabaseUrl already
68
- # normalises it; this method duplicates the strip instead of calling
69
- # it, which is how the two drifted.
70
- normalised = connection_string.sub(/^sqlite3:/, "sqlite:")
71
- raw = normalised.sub(/^sqlite:\/\/\//, "").sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
72
- return ":memory:" if raw == ":memory:"
73
-
74
- is_windows_abs = raw.match?(/^[A-Za-z]:[\/\\]/)
75
- is_unix_abs = raw.start_with?("/")
76
-
77
- if is_windows_abs || is_unix_abs
78
- # Absolute — trust the user; don't auto-mkdir outside cwd.
79
- raw
80
- else
81
- # Relative — resolve under cwd; auto-mkdir parent dir.
82
- resolved = File.join(Dir.pwd, raw)
83
- parent = File.dirname(resolved)
84
- require "fileutils"
85
- FileUtils.mkdir_p(parent) unless File.directory?(parent)
86
- resolved
87
- end
88
- end
89
-
90
60
  def close
91
61
  @connection&.close
92
62
  end
@@ -219,43 +189,6 @@ module Tina4
219
189
  def identifier?(str)
220
190
  str.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
221
191
  end
222
-
223
- # Symbolize the keys of a whole result set, computing the key mapping ONCE.
224
- #
225
- # results_as_hash=true hands back string-keyed hashes; every row of a query
226
- # has the same columns, so the string->symbol conversion is identical for
227
- # every row. The old code called `symbolize_keys` per row, which ran
228
- # `k.to_s.to_sym` and an `is_a?` guard PER CELL: a 5,000-row x 6-column
229
- # fetch did 30,000 conversions for 6 distinct keys and made Tina4 the
230
- # slowest of the four frameworks at bulk reads (Select ALL 5,000 rows:
231
- # 10.16ms vs raw sqlite3 2.56ms). Hoisting the mapping out of the loop
232
- # roughly halves the hydration cost with byte-identical output.
233
- #
234
- # The is_a?(String|Symbol) guard is preserved: older sqlite3 gems put
235
- # positional Integer keys in the hash alongside the string names, and those
236
- # must still be dropped.
237
- def symbolize_rows(rows)
238
- return rows if rows.empty?
239
-
240
- str_keys = rows.first.keys.select { |k| k.is_a?(String) || k.is_a?(Symbol) }
241
- sym_keys = str_keys.map(&:to_sym)
242
- count = str_keys.length
243
- rows.map do |row|
244
- out = {}
245
- i = 0
246
- while i < count
247
- out[sym_keys[i]] = row[str_keys[i]]
248
- i += 1
249
- end
250
- out
251
- end
252
- end
253
-
254
- # Single-hash convenience kept for any external caller; delegates to the
255
- # batch path so there is one implementation of the mapping rule.
256
- def symbolize_keys(hash)
257
- symbolize_rows([hash]).first
258
- end
259
192
  end
260
193
  end
261
194
  end
data/lib/tina4/env.rb CHANGED
@@ -5,8 +5,6 @@
5
5
  # License, v. 2.0. If a copy of the MPL was not distributed with this
6
6
  # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
 
8
- require "digest"
9
-
10
8
  module Tina4
11
9
  # Legacy env var names that v3.12 has retired. If any of these are set in
12
10
  # the environment we refuse to boot — silently ignoring them would cause
@@ -90,11 +88,15 @@ module Tina4
90
88
  # signal for Auth.ensure_dev_secret to mint a per-machine random dev secret
91
89
  # (saved to gitignored .env.local) in dev, or to emit the actionable
92
90
  # "set TINA4_SECRET" warning in CI/prod. Parity with the Python master.
91
+ #
92
+ # TINA4_DEBUG is deliberately absent too: unset debug means OFF, and a boot
93
+ # side effect must never switch it on (ADR-0079 s3). `tina4ruby init`, an
94
+ # explicit developer action, writes TINA4_DEBUG=true into the project it
95
+ # scaffolds.
93
96
  DEFAULT_ENV = {
94
97
  "PROJECT_NAME" => "Tina4 Ruby Project",
95
98
  "TINA4_SWAGGER_VERSION" => "1.0.0",
96
99
  "TINA4_LOCALE" => "en",
97
- "TINA4_DEBUG" => "true",
98
100
  "TINA4_LOG_LEVEL" => "ALL"
99
101
  }.freeze
100
102
 
@@ -266,11 +268,11 @@ module Tina4
266
268
  File.join(root_dir, ".env")
267
269
  end
268
270
 
271
+ # A missing .env gets the neutral defaults only. It no longer mints a
272
+ # TINA4_API_KEY: an MD5 of the clock is guessable, and that key opened
273
+ # the route auth gate and the dev-admin write gate (ADR-0079 s3).
269
274
  def create_default_env(path)
270
- api_key = Digest::MD5.hexdigest(Time.now.to_s)
271
- content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
272
- content += "\nTINA4_API_KEY=\"#{api_key}\"\n"
273
- File.write(path, content)
275
+ File.write(path, DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n") + "\n")
274
276
  end
275
277
 
276
278
  # Parse a dotenv file into ENV.
data/lib/tina4/frond.rb CHANGED
@@ -3258,9 +3258,11 @@ module Tina4
3258
3258
  sid = form_token_session_id.to_s
3259
3259
  payload["session_id"] = sid unless sid.empty?
3260
3260
 
3261
+ # get_token's expires_in is MINUTES, like every framework's. This used to
3262
+ # pass minutes * 60, so a form token lived 60 hours, not 60 minutes
3263
+ # (ADR-0079 s5).
3261
3264
  ttl_minutes = (ENV["TINA4_TOKEN_LIMIT"] || "60").to_i
3262
- expires_in = ttl_minutes * 60
3263
- Tina4::Auth.create_token(payload, expires_in: expires_in)
3265
+ Tina4::Auth.create_token(payload, expires_in: ttl_minutes)
3264
3266
  end
3265
3267
 
3266
3268
  def self.generate_form_token(descriptor = "")