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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/tina4/api.rb +17 -1
- data/lib/tina4/auth.rb +90 -14
- data/lib/tina4/cache_backends/base_backend.rb +8 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +16 -1
- data/lib/tina4/cache_backends.rb +3 -1
- data/lib/tina4/cli.rb +5 -0
- data/lib/tina4/database/sqlite3_adapter.rb +6 -54
- data/lib/tina4/dev_admin.rb +11 -4
- data/lib/tina4/drivers/sqlite_driver.rb +14 -81
- data/lib/tina4/env.rb +9 -7
- data/lib/tina4/frond.rb +4 -2
- data/lib/tina4/graphql.rb +106 -8
- data/lib/tina4/job.rb +15 -2
- data/lib/tina4/mcp.rb +3 -1
- data/lib/tina4/middleware.rb +3 -1
- data/lib/tina4/push.rb +13 -1
- data/lib/tina4/queue_backends/kafka_backend.rb +8 -0
- data/lib/tina4/queue_backends/lite_backend.rb +12 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +12 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +8 -0
- data/lib/tina4/rack_app.rb +52 -50
- data/lib/tina4/realtime/s3_storage.rb +9 -1
- data/lib/tina4/request.rb +5 -0
- data/lib/tina4/row_symbolizer.rb +54 -0
- data/lib/tina4/session_handlers/database_handler.rb +36 -12
- data/lib/tina4/sqlite_path_resolver.rb +72 -0
- data/lib/tina4/sso.rb +27 -4
- data/lib/tina4/ssrf.rb +148 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +160 -16
- data/lib/tina4/websocket_backplane.rb +6 -0
- data/lib/tina4/wsdl.rb +25 -4
- data/lib/tina4/xml_parser.rb +18 -9
- data/lib/tina4.rb +13 -2
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68bda3e81c456588a9a4db6501cb402b1eaa570d5f2bfbee0efb83bd9cb61beb
|
|
4
|
+
data.tar.gz: 61271a97c34bf49f68fcaf605d955d6e4f932f72fa370c884f4f0e91ef432e91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
|
|
129
|
-
|
|
189
|
+
!rsa_keys_present?
|
|
190
|
+
end
|
|
130
191
|
|
|
131
|
-
|
|
192
|
+
def rsa_keys_present?
|
|
132
193
|
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
133
|
-
|
|
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
|
-
|
|
411
|
-
|
|
481
|
+
else
|
|
482
|
+
valid_token(token)
|
|
412
483
|
end
|
|
413
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
|
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
|
data/lib/tina4/cache_backends.rb
CHANGED
|
@@ -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
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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
|
|
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 = "")
|