tina4ruby 3.13.94 → 3.13.97
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 +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +256 -33
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/README.md
CHANGED
|
@@ -58,7 +58,7 @@ db = Tina4::Database.new("sqlite://app.db")
|
|
|
58
58
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
59
59
|
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
60
60
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
61
|
-
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
61
|
+
| **Auth & Security** (5) | JWT (HS256/HS384/HS512 standard, RS256 opt-in, both stdlib OpenSSL, no gem), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
62
62
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8× faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
63
63
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
64
64
|
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -33,6 +33,28 @@ module Tina4
|
|
|
33
33
|
"HS512" => OpenSSL::Digest::SHA512
|
|
34
34
|
}.freeze
|
|
35
35
|
|
|
36
|
+
# RS256 (RSA-SHA256) is the OPT-IN asymmetric algorithm, and Ruby needs NO gem
|
|
37
|
+
# for it: OpenSSL is a stdlib DEFAULT GEM, so OpenSSL::PKey::RSA#sign/#verify
|
|
38
|
+
# emit and check exactly the RFC 7515 signature the other frameworks do
|
|
39
|
+
# (measured: a token minted here verifies under PHP openssl_verify AND Node
|
|
40
|
+
# crypto.createVerify, and a tampered payload goes INVALID in both). The `jwt`
|
|
41
|
+
# gem was declared only to wrap the base64url envelope this file already
|
|
42
|
+
# builds for HMAC, so it was dropped.
|
|
43
|
+
#
|
|
44
|
+
# The contract requires a LOUD, ACTIONABLE failure wherever RS256 is
|
|
45
|
+
# unavailable. In Ruby that branch is UNREACHABLE in practice: the
|
|
46
|
+
# `require "openssl"` at the top of this file is what supplies OpenSSL::HMAC
|
|
47
|
+
# for the HMAC path too, so an interpreter lacking it cannot load Tina4 at
|
|
48
|
+
# all. The message therefore names the real remedy and deliberately does NOT
|
|
49
|
+
# suggest installing a library — there is none to install.
|
|
50
|
+
RS256_UNAVAILABLE_MESSAGE =
|
|
51
|
+
"RS256 is unavailable: this Ruby has no OpenSSL::PKey::RSA. OpenSSL is a " \
|
|
52
|
+
"Ruby stdlib DEFAULT GEM, so this interpreter was built without the openssl " \
|
|
53
|
+
"extension — rebuild Ruby with OpenSSL support, or use a build that ships " \
|
|
54
|
+
"it. Do NOT install a JWT gem: Tina4 signs and verifies RS256 with stdlib " \
|
|
55
|
+
"OpenSSL and needs no third-party library. Tina4's standard algorithms are " \
|
|
56
|
+
"HS256/HS384/HS512, which are stdlib OpenSSL too."
|
|
57
|
+
|
|
36
58
|
# Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without
|
|
37
59
|
# this a token minted on one host and validated on another a second behind is
|
|
38
60
|
# rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
|
|
@@ -155,75 +177,114 @@ module Tina4
|
|
|
155
177
|
OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input)
|
|
156
178
|
end
|
|
157
179
|
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
def
|
|
162
|
-
alg = resolve_algorithm(algorithm)
|
|
163
|
-
header = { "alg" => alg, "typ" => "JWT" }
|
|
180
|
+
# Write the base64url header.payload.signature envelope. The signature bytes
|
|
181
|
+
# come from the block, so the HMAC and RS256 paths share ONE envelope writer
|
|
182
|
+
# — and one place where "alg" is stamped from whatever actually signed.
|
|
183
|
+
def encode_envelope(algorithm, claims)
|
|
164
184
|
segments = [
|
|
165
|
-
base64url_encode(JSON.generate(
|
|
185
|
+
base64url_encode(JSON.generate({ "alg" => algorithm, "typ" => "JWT" })),
|
|
166
186
|
base64url_encode(JSON.generate(claims))
|
|
167
187
|
]
|
|
168
|
-
|
|
169
|
-
segments << base64url_encode(hmac_signature(alg, secret, signing_input))
|
|
188
|
+
segments << base64url_encode(yield(segments.join(".")))
|
|
170
189
|
segments.join(".")
|
|
171
190
|
end
|
|
172
191
|
|
|
173
|
-
#
|
|
192
|
+
# Read the envelope, PIN the header alg, check the signature via the block,
|
|
193
|
+
# then apply the RFC 7519 claim rules. Returns the payload hash or nil.
|
|
174
194
|
#
|
|
175
195
|
# The algorithm is PINNED to our configured one rather than trusted from the
|
|
176
196
|
# token: a header asking to be verified as anything else — "none", a
|
|
177
|
-
# different HMAC, or
|
|
178
|
-
# any signature work.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
197
|
+
# different HMAC, or RS256 while we are configured for HMAC — is rejected
|
|
198
|
+
# before any signature work. Making RS256 opt-in must NOT open algorithm
|
|
199
|
+
# substitution, so both paths share this one pin.
|
|
200
|
+
def decode_envelope(token, algorithm)
|
|
201
|
+
parts = token.to_s.split(".")
|
|
202
|
+
return nil unless parts.length == 3
|
|
203
|
+
return nil unless JSON.parse(base64url_decode(parts[0]))["alg"] == algorithm
|
|
204
|
+
return nil unless yield("#{parts[0]}.#{parts[1]}", base64url_decode(parts[2]))
|
|
183
205
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return nil unless parts.length == 3
|
|
206
|
+
payload = JSON.parse(base64url_decode(parts[1]))
|
|
207
|
+
now = Time.now.to_i
|
|
187
208
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
209
|
+
# RFC 7519 s4.1.4: "The processing of the 'exp' claim requires that the
|
|
210
|
+
# current date/time MUST be before the expiration date/time". now == exp
|
|
211
|
+
# is therefore ALREADY expired, so the test is >=.
|
|
212
|
+
#
|
|
213
|
+
# key? — NOT a truthiness test on the value. A PRESENT but malformed exp
|
|
214
|
+
# must never read as "no constraint": `payload["exp"] && ...` skipped the
|
|
215
|
+
# check entirely for exp: null / exp: false, turning a broken token into
|
|
216
|
+
# one that never expires.
|
|
217
|
+
if payload.key?("exp")
|
|
218
|
+
exp = numeric_date(payload["exp"])
|
|
219
|
+
return nil if exp.nil? || now >= exp
|
|
220
|
+
end
|
|
191
221
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
222
|
+
# "nbf" (not-before): a post-dated token is not valid yet. Tolerate
|
|
223
|
+
# JWT_LEEWAY_SECONDS of clock skew so a token minted on a host a second
|
|
224
|
+
# ahead is not rejected for nothing. Same malformed-is-rejected rule as
|
|
225
|
+
# exp; NO nbf key at all stays unconstrained (non-breaking).
|
|
226
|
+
if payload.key?("nbf")
|
|
227
|
+
nbf = numeric_date(payload["nbf"])
|
|
228
|
+
return nil if nbf.nil? || now + JWT_LEEWAY_SECONDS < nbf
|
|
229
|
+
end
|
|
196
230
|
|
|
231
|
+
payload
|
|
232
|
+
rescue ArgumentError, JSON::ParserError, OpenSSL::OpenSSLError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Build a JWT with Ruby's OpenSSL::HMAC (no gem needed). The algorithm is the
|
|
237
|
+
# explicit argument, else TINA4_JWT_ALGORITHM, else HS256 — and the header
|
|
238
|
+
# advertises exactly the algorithm that signed.
|
|
239
|
+
def hmac_encode(claims, secret, algorithm: nil)
|
|
240
|
+
alg = resolve_algorithm(algorithm)
|
|
241
|
+
encode_envelope(alg, claims) { |input| hmac_signature(alg, secret, input) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Decode and verify an HMAC-signed JWT. Returns the payload hash or nil.
|
|
245
|
+
def hmac_decode(token, secret, algorithm: nil)
|
|
246
|
+
# Resolved OUTSIDE decode_envelope's rescue: an unsupported algorithm is a
|
|
247
|
+
# configuration error that must surface, not become a nil "invalid token".
|
|
248
|
+
alg = resolve_algorithm(algorithm)
|
|
249
|
+
|
|
250
|
+
decode_envelope(token, alg) do |input, signature|
|
|
251
|
+
expected = hmac_signature(alg, secret, input)
|
|
197
252
|
# Constant-time comparison to prevent timing attacks. Lengths must match
|
|
198
|
-
# first — fixed_length_secure_compare
|
|
253
|
+
# first — fixed_length_secure_compare RAISES on a length mismatch, which
|
|
199
254
|
# a forged signature of the wrong digest size would trigger.
|
|
200
|
-
|
|
201
|
-
|
|
255
|
+
expected.bytesize == signature.bytesize &&
|
|
256
|
+
OpenSSL.fixed_length_secure_compare(expected, signature)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
202
259
|
|
|
203
|
-
|
|
260
|
+
# ── RS256: opt-in, stdlib OpenSSL, zero gems ─────────────────
|
|
204
261
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
262
|
+
# Does this runtime provide RSA natively? Ruby ships OpenSSL as a stdlib
|
|
263
|
+
# DEFAULT GEM, so this is true on every normal build — there is nothing to
|
|
264
|
+
# install and nothing to opt into at the library level.
|
|
265
|
+
def rs256_available?
|
|
266
|
+
defined?(OpenSSL::PKey::RSA) ? true : false
|
|
267
|
+
end
|
|
208
268
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
269
|
+
# RSA-SHA256, with a FRESH digest instance per call so nothing about the
|
|
270
|
+
# digest state is shared between calls or threads (same rule as HMAC).
|
|
271
|
+
def rs256_encode(claims)
|
|
272
|
+
require_rs256!
|
|
273
|
+
encode_envelope("RS256", claims) { |input| private_key.sign(OpenSSL::Digest::SHA256.new, input) }
|
|
274
|
+
end
|
|
213
275
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
end
|
|
276
|
+
def rs256_decode(token)
|
|
277
|
+
require_rs256!
|
|
278
|
+
decode_envelope(token, "RS256") { |input, sig| public_key.verify(OpenSSL::Digest::SHA256.new, sig, input) }
|
|
218
279
|
end
|
|
219
280
|
|
|
220
|
-
# ── Token API (
|
|
281
|
+
# ── Token API (HMAC by default; RS256 when RSA keys are present) ──
|
|
221
282
|
|
|
222
283
|
# Mint a signed JWT.
|
|
223
284
|
#
|
|
224
285
|
# `algorithm:` selects the HMAC algorithm (else TINA4_JWT_ALGORITHM, else
|
|
225
286
|
# HS256); an unsupported one raises ArgumentError rather than quietly
|
|
226
|
-
# downgrading. It applies to the HMAC path only — the
|
|
287
|
+
# downgrading. It applies to the HMAC path only — the opt-in RS256 path
|
|
227
288
|
# (RSA keys present in .keys/) is unaffected.
|
|
228
289
|
#
|
|
229
290
|
# BREAKING (deliberate): no "nbf" claim is stamped. It duplicated "iat",
|
|
@@ -244,17 +305,15 @@ module Tina4
|
|
|
244
305
|
hmac_encode(claims, hmac_secret, algorithm: algorithm)
|
|
245
306
|
else
|
|
246
307
|
ensure_keys
|
|
247
|
-
|
|
248
|
-
JWT.encode(claims, private_key, "RS256")
|
|
308
|
+
rs256_encode(claims)
|
|
249
309
|
end
|
|
250
310
|
end
|
|
251
311
|
|
|
252
|
-
|
|
253
312
|
# Verify a JWT signature + expiry.
|
|
254
313
|
#
|
|
255
314
|
# 3.13.0: return type changed from `Boolean` to `Hash | nil`. The
|
|
256
315
|
# decoded payload is returned on success, nil on failure. Matches
|
|
257
|
-
#
|
|
316
|
+
# Python's Auth.valid_token in 3.13.0.
|
|
258
317
|
#
|
|
259
318
|
# Legacy `if Tina4::Auth.valid_token(t)` patterns keep working
|
|
260
319
|
# because a non-empty Hash is truthy and nil is falsy.
|
|
@@ -263,32 +322,17 @@ module Tina4
|
|
|
263
322
|
hmac_decode(token, hmac_secret) # returns Hash payload or nil
|
|
264
323
|
else
|
|
265
324
|
ensure_keys
|
|
266
|
-
|
|
267
|
-
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
268
|
-
decoded[0] # firebase/jwt-ruby returns [payload, header]
|
|
325
|
+
rs256_decode(token)
|
|
269
326
|
end
|
|
270
|
-
rescue JWT::ExpiredSignature, JWT::DecodeError
|
|
271
|
-
nil
|
|
272
327
|
end
|
|
273
328
|
|
|
329
|
+
# BREAKING (deliberate): the RS256 branch used to surface the jwt gem's own
|
|
330
|
+
# wording ("Signature has expired", or a raw decode message). Both paths now
|
|
331
|
+
# report the single HMAC-path wording, so the detail shape no longer depends
|
|
332
|
+
# on which algorithm signed.
|
|
274
333
|
def valid_token_detail(token)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if payload
|
|
278
|
-
{ valid: true, payload: payload }
|
|
279
|
-
else
|
|
280
|
-
{ valid: false, error: "Invalid or expired token" }
|
|
281
|
-
end
|
|
282
|
-
else
|
|
283
|
-
ensure_keys
|
|
284
|
-
require "jwt"
|
|
285
|
-
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
286
|
-
{ valid: true, payload: decoded[0] }
|
|
287
|
-
end
|
|
288
|
-
rescue JWT::ExpiredSignature
|
|
289
|
-
{ valid: false, error: "Token expired" }
|
|
290
|
-
rescue JWT::DecodeError => e
|
|
291
|
-
{ valid: false, error: e.message }
|
|
334
|
+
payload = valid_token(token)
|
|
335
|
+
payload ? { valid: true, payload: payload } : { valid: false, error: "Invalid or expired token" }
|
|
292
336
|
end
|
|
293
337
|
|
|
294
338
|
def hash_password(password, salt = nil, iterations = 260000)
|
|
@@ -349,23 +393,31 @@ module Tina4
|
|
|
349
393
|
|
|
350
394
|
token = Regexp.last_match(1)
|
|
351
395
|
|
|
396
|
+
# The JWT is checked FIRST, then the API key — the order Python, PHP and
|
|
397
|
+
# Node all use. Ruby checked the API key first, so the same request could
|
|
398
|
+
# authenticate differently depending on the framework.
|
|
399
|
+
#
|
|
400
|
+
# A custom secret and/or algorithm validates against those directly rather
|
|
401
|
+
# than this process's env-resolved defaults.
|
|
402
|
+
payload = if secret || algorithm
|
|
403
|
+
hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
|
|
404
|
+
elsif valid_token(token)
|
|
405
|
+
get_payload(token)
|
|
406
|
+
end
|
|
407
|
+
return payload if payload
|
|
408
|
+
|
|
352
409
|
# API_KEY bypass — timing-safe comparison via validate_api_key
|
|
353
410
|
# (OpenSSL.fixed_length_secure_compare). Parity with Python's
|
|
354
411
|
# authenticate_request (validate_api_key), PHP (hash_equals) and
|
|
355
412
|
# Node (timingSafeEqual). Never use a plain `==` here — that leaks the
|
|
356
413
|
# key length/prefix through comparison timing.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
# directly rather than this process's env-resolved defaults.
|
|
363
|
-
if secret || algorithm
|
|
364
|
-
payload = hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
|
|
365
|
-
return payload ? payload : nil
|
|
366
|
-
end
|
|
414
|
+
#
|
|
415
|
+
# "_auth" is the cross-framework key for a non-JWT auth result; PHP and
|
|
416
|
+
# Node already used it, Python used "auth_type" and Ruby "api_key", so the
|
|
417
|
+
# same successful auth read three different ways.
|
|
418
|
+
return { "_auth" => "api_key" } if validate_api_key(token)
|
|
367
419
|
|
|
368
|
-
|
|
420
|
+
nil
|
|
369
421
|
end
|
|
370
422
|
|
|
371
423
|
def validate_api_key(provided, expected: nil)
|
|
@@ -392,22 +444,24 @@ module Tina4
|
|
|
392
444
|
|
|
393
445
|
token = Regexp.last_match(1)
|
|
394
446
|
|
|
447
|
+
# JWT first, then the API key — same order and same payload shape as
|
|
448
|
+
# authenticate_request above (and as Python/PHP/Node).
|
|
449
|
+
if valid_token(token)
|
|
450
|
+
env["tina4.auth"] = get_payload(token)
|
|
451
|
+
return true
|
|
452
|
+
end
|
|
453
|
+
|
|
395
454
|
# API_KEY bypass — timing-safe comparison via validate_api_key
|
|
396
455
|
# (OpenSSL.fixed_length_secure_compare). Parity with Python's
|
|
397
456
|
# authenticate_request (validate_api_key), PHP (hash_equals) and
|
|
398
457
|
# Node (timingSafeEqual). Never use a plain `==` here — that leaks the
|
|
399
458
|
# key length/prefix through comparison timing.
|
|
400
459
|
if validate_api_key(token)
|
|
401
|
-
env["tina4.auth"] = { "
|
|
460
|
+
env["tina4.auth"] = { "_auth" => "api_key" }
|
|
402
461
|
return true
|
|
403
462
|
end
|
|
404
463
|
|
|
405
|
-
|
|
406
|
-
env["tina4.auth"] = get_payload(token)
|
|
407
|
-
true
|
|
408
|
-
else
|
|
409
|
-
false
|
|
410
|
-
end
|
|
464
|
+
false
|
|
411
465
|
end
|
|
412
466
|
end
|
|
413
467
|
|
|
@@ -431,6 +485,31 @@ module Tina4
|
|
|
431
485
|
|
|
432
486
|
private
|
|
433
487
|
|
|
488
|
+
# The loud, actionable RS256 gate. NotImplementedError is deliberate: it is
|
|
489
|
+
# Ruby's "not available on this platform" error AND it is NOT a
|
|
490
|
+
# StandardError, so a caller's blanket `rescue => e` cannot swallow it into
|
|
491
|
+
# a mysterious false. Unreachable on any Ruby that can load this file.
|
|
492
|
+
def require_rs256!
|
|
493
|
+
raise NotImplementedError, RS256_UNAVAILABLE_MESSAGE unless rs256_available?
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Coerce an RFC 7519 NumericDate claim to integer seconds, else nil.
|
|
497
|
+
#
|
|
498
|
+
# RFC 7519 s2 defines exp/nbf/iat as a NumericDate — a JSON numeric value.
|
|
499
|
+
# A claim that is PRESENT but not a number is malformed, and a malformed
|
|
500
|
+
# constraint must never read as "no constraint": treating a non-numeric exp
|
|
501
|
+
# as absent turns a broken token into one that never expires. Every
|
|
502
|
+
# non-numeric JSON type (null, true/false, String, Array, Hash) returns nil
|
|
503
|
+
# so the caller rejects the token. Mirrors the Python master's
|
|
504
|
+
# _numeric_date; Ruby needs no bool special-case because TrueClass is not
|
|
505
|
+
# an Integer (in Python bool IS an int subclass, so exp: true would compare
|
|
506
|
+
# as 1970).
|
|
507
|
+
def numeric_date(value)
|
|
508
|
+
return nil unless value.is_a?(Integer) || value.is_a?(Float)
|
|
509
|
+
|
|
510
|
+
value.to_i
|
|
511
|
+
end
|
|
512
|
+
|
|
434
513
|
# ── Dev-secret bootstrap helpers (parity with Python master) ──
|
|
435
514
|
|
|
436
515
|
# Dev when the framework debug flag is truthy (TINA4_DEBUG).
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -105,22 +105,30 @@ module Tina4
|
|
|
105
105
|
total = model_class.count
|
|
106
106
|
else
|
|
107
107
|
where_clause = filter_conditions.join(" AND ")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
#
|
|
111
|
-
|
|
108
|
+
# Fetch THIS page in SQL (limit + offset) and take the total from a
|
|
109
|
+
# COUNT(*) over the same filter — never rows-returned, never an
|
|
110
|
+
# in-memory re-slice by the absolute offset (ADR-0043 root causes 2
|
|
111
|
+
# and 3, which returned zero rows for a valid page).
|
|
112
|
+
records = model_class.where(where_clause, filter_values,
|
|
113
|
+
limit: limit, offset: offset, order_by: order_by)
|
|
114
|
+
total = model_class.count(where_clause, filter_values)
|
|
112
115
|
end
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
# Build the envelope through the ONE canonical derivation — the same
|
|
118
|
+
# DatabaseResult#to_paginate ADR-0043 fixed — so the REST list endpoint
|
|
119
|
+
# can never drift from it again. Exactly seven snake_case keys:
|
|
120
|
+
# records, total, page, per_page, total_pages, limit, offset. `records`
|
|
121
|
+
# is this page verbatim, `total` the true COUNT, and page/per_page/
|
|
122
|
+
# total_pages/limit/offset are all derived from the query that ran.
|
|
123
|
+
page_result = Tina4::DatabaseResult.new(
|
|
124
|
+
records.map { |r| r.to_h }, count: total, limit: limit, offset: offset
|
|
125
|
+
)
|
|
126
|
+
res.json(page_result.to_paginate)
|
|
120
127
|
rescue => e
|
|
121
128
|
res.json({ error: e.message }, status: 500)
|
|
122
129
|
end
|
|
123
|
-
}, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s]
|
|
130
|
+
}, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s],
|
|
131
|
+
model: model_class, model_list: true })
|
|
124
132
|
|
|
125
133
|
# GET /api/{table}/{id} -- get single record
|
|
126
134
|
Tina4::Router.add("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
@@ -135,7 +143,8 @@ module Tina4
|
|
|
135
143
|
rescue => e
|
|
136
144
|
res.json({ error: e.message }, status: 500)
|
|
137
145
|
end
|
|
138
|
-
}, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s]
|
|
146
|
+
}, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s],
|
|
147
|
+
model: model_class })
|
|
139
148
|
|
|
140
149
|
# POST /api/{table} -- create record
|
|
141
150
|
post_route = Tina4::Router.add("POST", "#{prefix}/#{table}", proc { |req, res|
|
|
@@ -153,16 +162,11 @@ module Tina4
|
|
|
153
162
|
}, swagger_meta: {
|
|
154
163
|
summary: "Create #{pretty_name}",
|
|
155
164
|
tags: [table.to_s],
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"schema" => { "type" => "object" },
|
|
162
|
-
"example" => example_body
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
165
|
+
# Reference the model's components.schemas entry ($ref, keyed by the
|
|
166
|
+
# model CLASS name) instead of a bare {type:object}; the generated
|
|
167
|
+
# example stays as a sample body. See decisions doc S2.
|
|
168
|
+
model: model_class,
|
|
169
|
+
example: example_body
|
|
166
170
|
})
|
|
167
171
|
|
|
168
172
|
# Secure-by-default: only opt out of the write gate when public: true.
|
|
@@ -194,16 +198,9 @@ module Tina4
|
|
|
194
198
|
}, swagger_meta: {
|
|
195
199
|
summary: "Update #{pretty_name}",
|
|
196
200
|
tags: [table.to_s],
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
"content" => {
|
|
201
|
-
"application/json" => {
|
|
202
|
-
"schema" => { "type" => "object" },
|
|
203
|
-
"example" => example_body
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
201
|
+
# $ref the model schema instead of {type:object} (see decisions S2).
|
|
202
|
+
model: model_class,
|
|
203
|
+
example: example_body
|
|
207
204
|
})
|
|
208
205
|
|
|
209
206
|
# Secure-by-default: only opt out of the write gate when public: true.
|
|
@@ -45,6 +45,25 @@ module Tina4
|
|
|
45
45
|
raise NotImplementedError
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# Evict expired entries and return HOW MANY were evicted.
|
|
49
|
+
#
|
|
50
|
+
# The base answer is 0, and it is an honest one: redis, valkey, memcached
|
|
51
|
+
# and mongodb expire entries SERVER-SIDE, so by the time a sweep runs
|
|
52
|
+
# there is nothing left for this process to reclaim. Backends that hold
|
|
53
|
+
# entries locally - memory, file, database - override this with a real
|
|
54
|
+
# count.
|
|
55
|
+
#
|
|
56
|
+
# It lives on the BASE so sweep is answerable on every provider. It used
|
|
57
|
+
# to exist only on the file backend, so sweep raised NoMethodError on the
|
|
58
|
+
# other six and every caller grew a respond_to?(:sweep) guard - and a
|
|
59
|
+
# guard cannot tell "not supported" from "evicted nothing", which are
|
|
60
|
+
# opposite readings for whoever is watching the number.
|
|
61
|
+
#
|
|
62
|
+
# @return [Integer] number of entries evicted, never negative
|
|
63
|
+
def sweep
|
|
64
|
+
0
|
|
65
|
+
end
|
|
66
|
+
|
|
48
67
|
# Whether this backend is actually usable (driver present + service
|
|
49
68
|
# reachable). Local backends are always available; network/driver backends
|
|
50
69
|
# override this so the factory can fall back to the file backend.
|
|
@@ -102,6 +102,35 @@ module Tina4
|
|
|
102
102
|
"database"
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
# Evict expired rows and return how many were deleted.
|
|
106
|
+
#
|
|
107
|
+
# A SQL table expires NOTHING by itself. Unlike redis/valkey/memcached/
|
|
108
|
+
# mongodb, which reclaim server-side, an expired row here sits in
|
|
109
|
+
# tina4_cache until something removes it - and the only thing that did was
|
|
110
|
+
# get(), which deletes a single row when someone happens to re-read that
|
|
111
|
+
# exact key. A key never read again was never reclaimed, so the table grew
|
|
112
|
+
# without bound while sweep, the one API whose job is reclaiming it,
|
|
113
|
+
# returned 0 and did nothing.
|
|
114
|
+
#
|
|
115
|
+
# expires_at > 0 is the guard that matters: this backend stores 0 for "no
|
|
116
|
+
# expiry" (get() uses the same guard), so a sweep comparing only
|
|
117
|
+
# expires_at < now would delete every permanent entry on its first run.
|
|
118
|
+
#
|
|
119
|
+
# COUNT then DELETE, against one captured timestamp, so the number
|
|
120
|
+
# returned is exactly the number of rows the DELETE removes on every
|
|
121
|
+
# engine - affected-row counts are best-effort outside SQLite.
|
|
122
|
+
def sweep
|
|
123
|
+
now = Time.now.to_f
|
|
124
|
+
row = @db.fetch_one(
|
|
125
|
+
"SELECT COUNT(*) AS c FROM tina4_cache WHERE expires_at > 0 AND expires_at < ?", [now]
|
|
126
|
+
)
|
|
127
|
+
expired = row ? (row["c"] || row[:c]).to_i : 0
|
|
128
|
+
return 0 if expired.zero?
|
|
129
|
+
|
|
130
|
+
@db.execute("DELETE FROM tina4_cache WHERE expires_at > 0 AND expires_at < ?", [now])
|
|
131
|
+
expired
|
|
132
|
+
end
|
|
133
|
+
|
|
105
134
|
private
|
|
106
135
|
|
|
107
136
|
def env_nonempty(key)
|