tina4ruby 3.13.97 → 3.13.99

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +24 -2
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -19
  51. data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
  52. data/lib/tina4/scss/tina4css/_badges.scss +0 -22
  53. data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
  54. data/lib/tina4/scss/tina4css/_cards.scss +0 -49
  55. data/lib/tina4/scss/tina4css/_forms.scss +0 -156
  56. data/lib/tina4/scss/tina4css/_grid.scss +0 -81
  57. data/lib/tina4/scss/tina4css/_modals.scss +0 -84
  58. data/lib/tina4/scss/tina4css/_nav.scss +0 -149
  59. data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
  60. data/lib/tina4/scss/tina4css/_reset.scss +0 -94
  61. data/lib/tina4/scss/tina4css/_tables.scss +0 -54
  62. data/lib/tina4/scss/tina4css/_typography.scss +0 -55
  63. data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
  64. data/lib/tina4/scss/tina4css/_variables.scss +0 -117
  65. data/lib/tina4/scss/tina4css/base.scss +0 -1
  66. data/lib/tina4/scss/tina4css/colors.scss +0 -48
  67. data/lib/tina4/scss/tina4css/tina4.scss +0 -18
@@ -383,6 +383,16 @@ module Tina4
383
383
  path = env["PATH_INFO"] || "/"
384
384
  method = env["REQUEST_METHOD"]
385
385
 
386
+ # DEVADMIN-DEC-01/02 (feature 127): fail-closed same-origin + loopback
387
+ # gate on every /__dev write, before ANY handler runs. Closes drive-by
388
+ # CSRF (a page the developer also has open POSTing to /file/save then
389
+ # /reload) and a network-exposed debug box. GET/HEAD/OPTIONS skip it;
390
+ # the MCP surface keeps its own richer 404 gate (with_mcp_gate), so the
391
+ # loopback arm skips the mcp prefixes. Mirrors Python's
392
+ # dev_admin._dev_mutation_denial wired into _handle_dev_admin.
393
+ denial = dev_mutation_denial(env)
394
+ return denial if denial
395
+
386
396
  # Dynamic-path routes can't live in the case-tuple below — match
387
397
  # them up front. /__dev/api/threads/{id}[/messages] is forwarded
388
398
  # verbatim to the Rust agent's /threads/{id}[/messages] surface
@@ -665,7 +675,10 @@ module Tina4
665
675
  when ["GET", "/__dev/api/files"]
666
676
  json_response(files_list(env))
667
677
  when ["GET", "/__dev/api/file"]
668
- json_response(file_read_payload(query_param(env, "path")))
678
+ rel = query_param(env, "path")
679
+ # DEVADMIN-DEC-03: a secret path refuses with 403 (parity with Python);
680
+ # file_read_payload also guards so the body never carries the content.
681
+ json_response(file_read_payload(rel), secret_path?(rel) ? 403 : 200)
669
682
  when ["GET", "/__dev/api/file/raw"]
670
683
  file_raw_response(query_param(env, "path"))
671
684
  when ["POST", "/__dev/api/file/save"]
@@ -758,9 +771,9 @@ module Tina4
758
771
  JSON.parse(raw) rescue nil
759
772
  end
760
773
 
761
- def json_response(data)
774
+ def json_response(data, status = 200)
762
775
  body = JSON.generate(data)
763
- [200, { "content-type" => "application/json; charset=utf-8" }, [body]]
776
+ [status, { "content-type" => "application/json; charset=utf-8" }, [body]]
764
777
  end
765
778
 
766
779
  def version_check_payload
@@ -1036,7 +1049,12 @@ module Tina4
1036
1049
  url = grounding_env_value("TINA4_MCP_URL").to_s
1037
1050
  url = "https://mcp.tina4.com" if url.empty?
1038
1051
  configured = !token.empty?
1039
- { configured: configured, last4: configured ? token[-4..].to_s : "", url: url }
1052
+ # source: the developer's own token "personal"; otherwise the coder
1053
+ # runs on the shared FREE-TOKEN trial the Rust agent falls back to →
1054
+ # "free", which drives the "register" nudge. (Disabling the free rung
1055
+ # is an agent-side concern.)
1056
+ source = configured ? "personal" : "free"
1057
+ { configured: configured, source: source, last4: configured ? token[-4..].to_s : "", url: url }
1040
1058
  end
1041
1059
 
1042
1060
  # POST /__dev/api/grounding/token — upsert TINA4_MCP_TOKEN into the
@@ -1349,13 +1367,22 @@ module Tina4
1349
1367
  # which a bare Tina4.seed_table call never trips (mirrors Python's
1350
1368
  # explicit `from tina4_python.seeder import seed_table`).
1351
1369
  require_relative "seeder"
1370
+ # SEED-TABLE-SEED-INERT: seed_table no longer takes seed: (it has no
1371
+ # generators of its own). Build the field map from the introspected
1372
+ # columns using a CALLER-SEEDED FakeData closed over per generator —
1373
+ # the same "auto_field_map(db, table, fake)" pattern Python/Node use
1374
+ # for their dev-admin seed endpoints — so reproducibility comes from
1375
+ # the closure, not a dead keyword.
1376
+ fake = Tina4::FakeData.new(seed: seed)
1377
+ field_map = Tina4._normalize_columns(db.columns(table_name)).each_with_object({}) do |(col_name, type_sym), map|
1378
+ map[col_name] = -> { fake.for_field({ type: type_sym }, col_name) }
1379
+ end
1352
1380
  # Delegate to the shared resilient seed_table helper so the endpoint
1353
1381
  # gets the exact same per-row wrap (P1) — no unhandled row failure can
1354
- # crash the endpoint — plus clear/seed/strict (P2/P3). _normalize_columns
1355
- # skips auto-increment id PKs from the introspected column list.
1382
+ # crash the endpoint — plus clear/strict (P2/P1).
1356
1383
  summary = Tina4.seed_table(
1357
- table_name, db.columns(table_name),
1358
- count: count, seed: seed, clear: clear, strict: strict
1384
+ table_name, field_map,
1385
+ count: count, clear: clear, strict: strict
1359
1386
  )
1360
1387
  {
1361
1388
  table: table_name,
@@ -1718,7 +1745,10 @@ module Tina4
1718
1745
 
1719
1746
  def dev_files_hidden?(name)
1720
1747
  return true if DEV_FILES_IGNORED.include?(name)
1721
- name.start_with?(".") && name != ".env" && name != ".env.example"
1748
+ # DEVADMIN-DEC-03: hide dotfiles INCLUDING .env (it carries TINA4_SECRET,
1749
+ # the DB password and the MCP token). The .env.example template is
1750
+ # placeholder-only and stays visible.
1751
+ name.start_with?(".") && name != ".env.example"
1722
1752
  end
1723
1753
 
1724
1754
  # Same 4-status mapping Python/PHP use for a porcelain code.
@@ -1792,6 +1822,10 @@ module Tina4
1792
1822
  next if dev_files_hidden?(name)
1793
1823
  full = File.join(target, name)
1794
1824
  entry_rel = full[root.length..].to_s.sub(%r{\A/+}, "").tr("\\", "/")
1825
+ # DEVADMIN-DEC-03: never surface secret material in the listing
1826
+ # (.pem/.key, id_rsa, secrets/, .git/). The dotfile filter above
1827
+ # already drops .env; this covers non-dotfile secrets too.
1828
+ next if secret_path?(entry_rel)
1795
1829
  is_dir = File.directory?(full)
1796
1830
 
1797
1831
  # git status for this entry (same mapping PHP/Python use)
@@ -1856,6 +1890,12 @@ module Tina4
1856
1890
 
1857
1891
  def file_read_payload(rel)
1858
1892
  return { error: "path required" } if rel.nil? || rel.empty?
1893
+ # DEVADMIN-DEC-03: never serve secret material (.env, keys, secrets/).
1894
+ # The dispatch statuses this 403; the guard here keeps the helper safe
1895
+ # for any other caller too. The secret content never enters the payload.
1896
+ if secret_path?(rel)
1897
+ return { error: "Refused: secret file", path: rel.to_s, content: "", language: "text", bytes: 0 }
1898
+ end
1859
1899
  begin
1860
1900
  target = safe_project_path(rel)
1861
1901
  return { error: "Not found" } unless File.exist?(target)
@@ -1869,6 +1909,8 @@ module Tina4
1869
1909
 
1870
1910
  def file_raw_response(rel)
1871
1911
  return json_response({ error: "path required" }) if rel.nil? || rel.empty?
1912
+ # DEVADMIN-DEC-03: never serve secret material (.env, keys, secrets/).
1913
+ return json_response({ error: "Refused: secret file" }, 403) if secret_path?(rel)
1872
1914
  begin
1873
1915
  target = safe_project_path(rel)
1874
1916
  return json_response({ error: "Not found" }) unless File.file?(target)
@@ -2068,6 +2110,92 @@ module Tina4
2068
2110
  res.zero?
2069
2111
  end
2070
2112
 
2113
+ # ── Dev-admin mutation security (feature 127, DEVADMIN-DEC-01/02/03) ──
2114
+ # The dashboard writes files, runs SQL and installs gems, so it must
2115
+ # assume the developer ALSO browses the web. Two fail-closed gates guard
2116
+ # every /__dev write, and a secret denylist guards the file-read surface.
2117
+ # Mirrors tina4_python/dev_admin _dev_same_origin_ok / _dev_mutation_denial
2118
+ # / _is_secret_path.
2119
+ DEV_SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
2120
+ # The MCP surface carries its own richer loopback+token gate
2121
+ # (with_mcp_gate -> 404), so the REST loopback arm skips these prefixes and
2122
+ # lets the MCP gate govern (keeps a disallowed mcp/call a 404, not a 403).
2123
+ DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"].freeze
2124
+ # Private-key / credential basenames the file endpoints must never serve.
2125
+ DEV_SECRET_BASENAMES = %w[.env .envrc id_rsa id_dsa id_ecdsa id_ed25519].freeze
2126
+ DEV_SECRET_SUFFIXES = %w[.pem .key .pfx .p12 .keystore .jks].freeze
2127
+
2128
+ # DEVADMIN-DEC-01: fail-closed same-origin check for a dev-admin mutation.
2129
+ #
2130
+ # A drive-by CSRF is a BROWSER cross-origin request, and a modern browser
2131
+ # always sends Sec-Fetch-Site (and any browser sends Origin on a
2132
+ # cross-origin POST), so:
2133
+ # - Sec-Fetch-Site present -> trust the browser's own classification
2134
+ # (cross-site refused; same-origin / same-site / none allowed).
2135
+ # - else Origin present -> require its host to match the request Host.
2136
+ # - else neither -> not a browser cross-origin request at all (curl, a
2137
+ # test client, a server-side caller); it cannot be a drive-by, so it
2138
+ # is allowed here and the loopback gate still constrains the peer.
2139
+ def dev_same_origin_ok?(env)
2140
+ sfs = (env["HTTP_SEC_FETCH_SITE"] || "").strip.downcase
2141
+ return %w[same-origin same-site none].include?(sfs) unless sfs.empty?
2142
+
2143
+ origin = (env["HTTP_ORIGIN"] || "").strip
2144
+ unless origin.empty?
2145
+ netloc = origin.include?("://") ? origin.split("://", 2)[1] : origin
2146
+ host = (env["HTTP_HOST"] || "").strip
2147
+ return !host.empty? && netloc.downcase == host.downcase
2148
+ end
2149
+ true
2150
+ end
2151
+
2152
+ # Return a 403 Rack triple to REFUSE a dev-admin write, or nil to allow.
2153
+ #
2154
+ # Two independent fail-closed gates on every /__dev mutation:
2155
+ # DEC-01 same-origin (all writes, incl. mcp/call) - drive-by CSRF.
2156
+ # DEC-02 loopback peer (all writes EXCEPT the MCP surface, which carries
2157
+ # its own gate) - a network-exposed debug box.
2158
+ # Uses the RAW socket peer (env["REMOTE_ADDR"]), never X-Forwarded-For.
2159
+ def dev_mutation_denial(env)
2160
+ method = (env["REQUEST_METHOD"] || "").to_s.upcase
2161
+ return nil if DEV_SAFE_METHODS.include?(method)
2162
+ path = (env["PATH_INFO"] || "").to_s
2163
+ return nil unless path.start_with?("/__dev")
2164
+
2165
+ return dev_refused("dev-admin: refused (cross-origin request)") unless dev_same_origin_ok?(env)
2166
+
2167
+ unless path.start_with?(*DEV_MCP_PREFIXES)
2168
+ remote_ip = (env["REMOTE_ADDR"] || "").to_s
2169
+ unless Tina4.is_loopback?(remote_ip) || mcp_token_ok?(env)
2170
+ return dev_refused("dev-admin: refused (non-loopback peer)")
2171
+ end
2172
+ end
2173
+ nil
2174
+ end
2175
+
2176
+ def dev_refused(message)
2177
+ [403, { "content-type" => "application/json; charset=utf-8" },
2178
+ [JSON.generate({ "ok" => false, "error" => message })]]
2179
+ end
2180
+
2181
+ # DEVADMIN-DEC-03: true when +rel+ names secret material the file endpoints
2182
+ # must never serve - .env / .env.* (the .env.example template is allowed),
2183
+ # anything under .git/ or secrets/, and private-key material.
2184
+ def secret_path?(rel)
2185
+ norm = rel.to_s.tr("\\", "/").gsub(%r{\A/+|/+\z}, "").downcase
2186
+ return false if norm.empty?
2187
+
2188
+ parts = norm.split("/")
2189
+ return true if parts.any? { |p| p == ".git" || p == "secrets" }
2190
+
2191
+ base = parts.last
2192
+ return false if base == ".env.example"
2193
+ return true if base == ".env" || base.start_with?(".env.")
2194
+ return true if DEV_SECRET_BASENAMES.include?(base)
2195
+
2196
+ base.end_with?(*DEV_SECRET_SUFFIXES)
2197
+ end
2198
+
2071
2199
  def mcp_tools_list
2072
2200
  return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
2073
2201
  server = Tina4._default_mcp_server
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "zlib"
4
+ require "stringio"
5
+ require "digest"
6
+
3
7
  module Tina4
4
8
  # The dispatch pipeline: the concerns of RackApp#call, named and ordered.
5
9
  #
@@ -67,7 +71,15 @@ module Tina4
67
71
  # the swagger and static branches - which return early - skipped it, and
68
72
  # `HEAD /style.css` shipped the whole file body. Measured 2026-07-31: Ruby
69
73
  # returned 15 bytes where PHP, Python and Node all returned 0.
74
+ #
75
+ # `compress_and_tag` and `conditional_get` (feature 40, CE-DEC-01/02) run
76
+ # FIRST - before head_strip - so a HEAD response's preserved
77
+ # Content-Length reflects the (possibly compressed) body the equivalent
78
+ # GET would have sent, mirroring the Python master's build_headers() ->
79
+ # app() ordering (compress -> tag -> 304-decide -> strip-for-HEAD).
70
80
  ALWAYS_STAGES = %i[
81
+ compress_and_tag
82
+ conditional_get
71
83
  head_strip
72
84
  apply_cors
73
85
  ].freeze
@@ -286,12 +298,136 @@ module Tina4
286
298
  return static_response
287
299
  end
288
300
 
289
- handle_404(ctx.path)
301
+ handle_404(ctx.path, ctx.env["HTTP_ACCEPT"])
290
302
  end
291
303
 
292
- # ── RESPONSE STAGES ──────────────────────────────────────────────
304
+ # ── ALWAYS STAGES (compression / ETag / conditional-GET) ──────────
293
305
  # Each returns a replacement Rack triple, or nil to leave it unchanged.
294
306
 
307
+ # Gzip-compress + attach an ETag (feature 40, CE-DEC-01) - the ONE header
308
+ # builder every response funnels through (a static asset or a dynamic
309
+ # route), mirroring the Python master's build_headers(). A streaming body
310
+ # (an Enumerator, from Response#stream) is never touched - joining it
311
+ # would drain an SSE generator instead of letting it stream live.
312
+ #
313
+ # Compression: the body exceeds 1024 bytes AND Accept-Encoding offers
314
+ # gzip AND the content type is compressible. Uses stdlib Zlib - zero new
315
+ # dependency.
316
+ #
317
+ # ETag: a strong md5 hash (first 16 hex chars) over the FINAL
318
+ # (post-compression) body, UNLESS a validator is already set - a
319
+ # static-file response (#serve_static_file) pins its own weak
320
+ # size+mtime ETag before this runs (CE-DEC-02), so this never overwrites
321
+ # it with a content hash.
322
+ def compress_and_tag(ctx, response)
323
+ status, headers, body_parts = response
324
+ return nil unless body_parts.is_a?(Array)
325
+
326
+ body = body_parts.join
327
+ new_headers = headers.dup
328
+ changed = false
329
+
330
+ if should_gzip?(ctx, body, new_headers)
331
+ body = gzip_string(body)
332
+ new_headers["content-encoding"] = "gzip"
333
+ new_headers["vary"] = "Accept-Encoding"
334
+ new_headers["content-length"] = body.bytesize.to_s if new_headers["content-length"]
335
+ changed = true
336
+ end
337
+
338
+ if should_tag_etag?(new_headers, body, status)
339
+ new_headers["etag"] = %("#{Digest::MD5.hexdigest(body)[0, 16]}")
340
+ changed = true
341
+ end
342
+
343
+ return nil unless changed
344
+
345
+ [status, new_headers, [body]]
346
+ end
347
+
348
+ # Split out of compress_and_tag (metrics/6b) to hold the complexity gate:
349
+ # the body-size / Accept-Encoding / content-type gzip decision, named so
350
+ # the caller reads as one line per concern instead of one three-way &&.
351
+ def should_gzip?(ctx, body, headers)
352
+ accept_encoding = ctx.env["HTTP_ACCEPT_ENCODING"] || ""
353
+ content_type = headers["content-type"] || ""
354
+ body.bytesize > 1024 && accept_encoding.include?("gzip") && compressible_content_type?(content_type)
355
+ end
356
+
357
+ # Split out of compress_and_tag (metrics/6b): whether this response earns
358
+ # a content-hash ETag - no validator set already, a non-empty body, a 200.
359
+ def should_tag_etag?(headers, body, status)
360
+ !headers["etag"] && !body.empty? && status == 200
361
+ end
362
+
363
+ # Answer a matching conditional GET with a 304 that PRESERVES whichever
364
+ # validators (ETag / Last-Modified) the 200 would have carried (feature
365
+ # 40). A static-file 304 (#serve_static_file's own short-circuit) is
366
+ # already terminal by the time this runs (status is 304, not 200), so the
367
+ # guard below leaves it untouched.
368
+ def conditional_get(ctx, response)
369
+ status, headers, body_parts = response
370
+ return nil unless status == 200 && body_parts.is_a?(Array)
371
+
372
+ body = body_parts.join
373
+ return nil if body.empty?
374
+
375
+ etag = headers["etag"]
376
+ last_modified = headers["last-modified"]
377
+ return nil unless request_not_modified?(ctx, etag, last_modified)
378
+
379
+ not_modified_headers = {}
380
+ not_modified_headers["etag"] = etag if etag
381
+ not_modified_headers["last-modified"] = last_modified if last_modified
382
+ [304, not_modified_headers, [""]]
383
+ end
384
+
385
+ # Split out of conditional_get (metrics/6b) to hold the complexity gate.
386
+ # RFC 7232 S6: If-None-Match, when present, decides it alone; only when
387
+ # the request sends no If-None-Match does If-Modified-Since apply.
388
+ def request_not_modified?(ctx, etag, last_modified)
389
+ if_none_match = ctx.env["HTTP_IF_NONE_MATCH"]
390
+ return etag_matches?(if_none_match, etag) if if_none_match && !if_none_match.empty?
391
+
392
+ if_modified_since = ctx.env["HTTP_IF_MODIFIED_SINCE"]
393
+ return false unless if_modified_since && !if_modified_since.empty? && last_modified
394
+
395
+ begin
396
+ Time.httpdate(last_modified).to_i <= Time.httpdate(if_modified_since).to_i
397
+ rescue ArgumentError
398
+ false # Unparseable date -> serve the body (never 304 on garbage).
399
+ end
400
+ end
401
+
402
+ # Whether a content type benefits from gzip compression (text-ish, JSON,
403
+ # XML, JS, SVG). Mirrors the Python master's `_is_compressible`.
404
+ def compressible_content_type?(content_type)
405
+ %w[text/ application/json application/xml application/javascript image/svg].any? do |needle|
406
+ content_type.include?(needle)
407
+ end
408
+ end
409
+
410
+ # Gzip-compress a string at level 6, in memory. Stdlib Zlib - zero new
411
+ # dependency.
412
+ def gzip_string(str)
413
+ io = StringIO.new
414
+ writer = Zlib::GzipWriter.new(io, 6)
415
+ writer.write(str)
416
+ writer.close
417
+ io.string
418
+ end
419
+
420
+ # Match an If-None-Match header value against `etag` (RFC 7232 S3.2 weak
421
+ # comparison). Shared with the static-file conditional-GET
422
+ # (#static_not_modified?) so BOTH paths use IDENTICAL matching semantics.
423
+ def etag_matches?(if_none_match, etag)
424
+ return false if etag.nil? || etag.empty?
425
+ return true if if_none_match.strip == "*"
426
+
427
+ want = weak_etag_value(etag)
428
+ if_none_match.split(",").any? { |candidate| weak_etag_value(candidate) == want }
429
+ end
430
+
295
431
  # RFC 9110 s9.3.2: a HEAD response MUST NOT include content. Strip the body
296
432
  # unconditionally and record what Content-Length the GET would have sent -
297
433
  # cache validators, link checkers and monitoring probes use that header to
@@ -458,7 +594,12 @@ module Tina4
458
594
  # are only known now, so they are attached here.
459
595
  def prepare_route_request(ctx)
460
596
  ctx.request = ctx.pre_request || Tina4::Request.new(ctx.env)
461
- ctx.request.path_params = ctx.path_params
597
+ ctx.request.params = ctx.path_params
598
+ # Attach the matched route so post-match middleware (CsrfMiddleware) can
599
+ # read its metadata -- e.g. auth_required to honour a public write route
600
+ # (.no_auth). Without this the CSRF no_auth skip is dead code, because the
601
+ # request otherwise carries no handler/route reference.
602
+ ctx.request.route = ctx.route
462
603
  ctx.env["tina4.request"] = ctx.request # Store for session save after response
463
604
  ctx.response = ctx.pre_response || Tina4::Response.new
464
605
  nil
@@ -485,7 +626,7 @@ module Tina4
485
626
  return nil unless ctx.route.auth_handler
486
627
  return nil if ctx.route.auth_handler.call(ctx.env)
487
628
 
488
- handle_403(ctx.env["PATH_INFO"] || "/")
629
+ handle_403(ctx.env["PATH_INFO"] || "/", ctx.env["HTTP_ACCEPT"])
489
630
  end
490
631
 
491
632
  # Secure-by-default: enforce bearer-token auth on write routes.
@@ -227,17 +227,25 @@ module Tina4
227
227
  @connection.query(:hash, sql, *params)
228
228
  end
229
229
  end
230
- rows.map { |row| decode_blobs(stringify_keys(row)) }
230
+ rows.map { |row| decode_blobs(symbolize_keys(row)) }
231
231
  end
232
232
 
233
233
  def execute(sql, params = [])
234
- with_reconnect do
234
+ result = with_reconnect do
235
235
  if params.empty?
236
236
  @connection.execute(sql)
237
237
  else
238
238
  @connection.execute(sql, *params)
239
239
  end
240
240
  end
241
+ # The fb gem's Connection#execute RETURNS the affected row count as an
242
+ # Integer for DML, so capture it for #affected_rows (FB-AFFECTED-FAB --
243
+ # the facade used to default it). Remember an INSERT's target table so
244
+ # #last_insert_id can read its GEN_<TABLE>_ID generator (FB-LASTID-GAP);
245
+ # a non-INSERT clears it so last_insert_id is nil there.
246
+ @affected_rows = result.is_a?(Integer) ? result : 0
247
+ @last_insert_table = insert_target_table(sql)
248
+ result
241
249
  end
242
250
 
243
251
  # Public so specs (and curious operators) can verify the matcher
@@ -249,10 +257,28 @@ module Tina4
249
257
  DEAD_CONN_MARKERS.any? { |m| lower.include?(m) }
250
258
  end
251
259
 
260
+ # Firebird has no generic last-insert-id, so read the GEN_<TABLE>_ID
261
+ # generator the row's BEFORE INSERT trigger drew from (FB-LASTID-GAP). The
262
+ # generator read is COLUMN-NAME-INDEPENDENT, so it is correct even for a
263
+ # non-`id` primary key. Returns nil when the last write was not an INSERT
264
+ # or the table has no such generator (GEN_ID then raises -> rescued).
252
265
  def last_insert_id
266
+ table = @last_insert_table
267
+ return nil if table.nil? || table.empty?
268
+ generator = "GEN_#{table.gsub('"', '').upcase}_ID"
269
+ row = execute_query("SELECT GEN_ID(#{generator}, 0) AS LID FROM RDB\$DATABASE").first
270
+ row && (row[:lid] || row[:LID])
271
+ rescue StandardError
253
272
  nil
254
273
  end
255
274
 
275
+ # Rows affected by the most recent write, read from the fb gem's execute()
276
+ # return (an Integer for DML). Best-effort 0 for DDL / a non-count return.
277
+ # The facade's write_affected reads this when the driver implements it.
278
+ def affected_rows
279
+ @affected_rows || 0
280
+ end
281
+
256
282
  def placeholder
257
283
  "?"
258
284
  end
@@ -317,10 +343,15 @@ module Tina4
317
343
  @in_transaction = false
318
344
  end
319
345
 
346
+ # ADR-0044 required adapter capability.
347
+ def get_database_type
348
+ 'firebird'
349
+ end
350
+
320
351
  def tables
321
352
  sql = "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL"
322
353
  rows = execute_query(sql)
323
- rows.map { |r| (r["RDB\$RELATION_NAME"] || r["rdb\$relation_name"] || "").strip }
354
+ rows.map { |r| (r[:"RDB\$RELATION_NAME"] || r[:"rdb\$relation_name"] || "").strip }
324
355
  end
325
356
 
326
357
  def columns(table_name)
@@ -341,7 +372,7 @@ module Tina4
341
372
  "ORDER BY SG.RDB\$FIELD_POSITION"
342
373
  pk_names = begin
343
374
  execute_query(pk_sql, [table_name.upcase]).map do |r|
344
- (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip.upcase
375
+ (r[:"RDB\$FIELD_NAME"] || r[:"rdb\$field_name"] || "").strip.upcase
345
376
  end.reject(&:empty?).to_set
346
377
  rescue StandardError
347
378
  # A table with no primary key is not an error.
@@ -349,12 +380,12 @@ module Tina4
349
380
  end
350
381
 
351
382
  rows.map do |r|
352
- field_name = (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip
383
+ field_name = (r[:"RDB\$FIELD_NAME"] || r[:"rdb\$field_name"] || "").strip
353
384
  {
354
385
  name: field_name,
355
- type: r["RDB\$FIELD_TYPE"] || r["rdb\$field_type"],
356
- nullable: (r["RDB\$NULL_FLAG"] || r["rdb\$null_flag"]).nil?,
357
- default: r["RDB\$DEFAULT_SOURCE"] || r["rdb\$default_source"],
386
+ type: r[:"RDB\$FIELD_TYPE"] || r[:"rdb\$field_type"],
387
+ nullable: (r[:"RDB\$NULL_FLAG"] || r[:"rdb\$null_flag"]).nil?,
388
+ default: r[:"RDB\$DEFAULT_SOURCE"] || r[:"rdb\$default_source"],
358
389
  primary_key: pk_names.include?(field_name.upcase)
359
390
  }
360
391
  end
@@ -390,8 +421,24 @@ module Tina4
390
421
  yield
391
422
  end
392
423
 
393
- def stringify_keys(hash)
394
- hash.each_with_object({}) { |(k, v), h| h[column_name(k.to_s)] = v }
424
+ # Hydrate a raw fb-gem row into a SYMBOL-keyed Hash — parity with the
425
+ # sqlite/postgres/mssql drivers (SqliteDriver#symbolize_rows,
426
+ # PostgresDriver#symbolize_result, MssqlDriver's `symbolize_keys: true`),
427
+ # all three of which hand back symbol keys. This used to build a
428
+ # STRING-keyed row (hence the old name `stringify_keys`), the one
429
+ # Firebird-only divergence in the whole row-hydration contract:
430
+ # `db.fetch(...).first[:name]` worked on every engine except Firebird,
431
+ # where only `row["name"]` did. column_name's casing fold-back is
432
+ # unchanged — only the key TYPE changes here.
433
+ def symbolize_keys(hash)
434
+ hash.each_with_object({}) { |(k, v), h| h[column_name(k.to_s).to_sym] = v }
435
+ end
436
+
437
+ # The target table of an INSERT (so last_insert_id can read its
438
+ # GEN_<TABLE>_ID generator), or nil for any other statement.
439
+ def insert_target_table(sql)
440
+ match = sql.to_s.match(/\AINSERT\s+INTO\s+([^\s(]+)/i)
441
+ match && match[1]
395
442
  end
396
443
 
397
444
  # Firebird's stored column name, folded back only when it was folded.
@@ -399,8 +446,8 @@ module Tina4
399
446
  # Firebird's identifier folding is ASYMMETRIC. An unquoted `AS x` is
400
447
  # stored UPPERCASE, so the driver hands back "X" where every other engine
401
448
  # Tina4 supports gives "x" -- PostgreSQL folds to lower, and MySQL, SQLite
402
- # and MSSQL preserve what you wrote. Portable code reading row["x"] broke
403
- # on Firebird alone; the live URL examples in this repo asserted row["x"]
449
+ # and MSSQL preserve what you wrote. Portable code reading row[:x] broke
450
+ # on Firebird alone; the live URL examples in this repo asserted row[:x]
404
451
  # and could never have passed once they actually reached a server.
405
452
  #
406
453
  # A QUOTED `AS "MyCol"` is stored exactly as written, and that case is