tina4ruby 3.13.137 → 3.13.139

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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
data/lib/tina4/cache.rb CHANGED
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  module Tina4
2
8
  # In-memory TTL cache with tag-based invalidation.
3
9
  #
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  module CacheBackends
@@ -72,6 +78,14 @@ module Tina4
72
78
  def available?
73
79
  true
74
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
75
89
  end
76
90
  end
77
91
  end
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require_relative "base_backend"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require "fileutils"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require "socket"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require_relative "base_backend"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require "uri"
@@ -17,6 +23,7 @@ module Tina4
17
23
  @hits = 0
18
24
  @misses = 0
19
25
  @coll = nil
26
+ @driver_missing = false
20
27
  begin
21
28
  require "mongo"
22
29
  Mongo::Logger.logger.level = Logger::FATAL if defined?(Mongo::Logger)
@@ -33,7 +40,12 @@ module Tina4
33
40
  @coll = client[:tina4_cache]
34
41
  @coll.indexes.create_one({ expires_at: 1 }, expire_after: 0)
35
42
  client.database.command(ping: 1)
36
- 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
37
49
  @coll = nil
38
50
  end
39
51
  end
@@ -42,6 +54,15 @@ module Tina4
42
54
  !@coll.nil?
43
55
  end
44
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
+
45
66
  def get(key)
46
67
  if @coll.nil?
47
68
  @misses += 1
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require "socket"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require_relative "redis_backend"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require_relative "cache_backends/base_backend"
4
10
  require_relative "cache_backends/memory_backend"
@@ -86,9 +92,11 @@ module Tina4
86
92
 
87
93
  # Configured backend unusable — degrade to the file backend.
88
94
  begin
95
+ install_hint = be.missing_driver_message
89
96
  Tina4::Log.warning(
90
97
  "Cache backend '#{backend}' is unavailable " \
91
- "(driver missing or service unreachable) — falling back to 'file'."
98
+ "(driver missing or service unreachable) — falling back to 'file'." \
99
+ "#{install_hint ? " #{install_hint}" : ""}"
92
100
  )
93
101
  rescue StandardError
94
102
  # Logging must never break cache construction.
data/lib/tina4/cli.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "optparse"
4
10
  require "fileutils"
@@ -8,6 +14,7 @@ require "tmpdir" # Dir.mktmpdir — used by the ADR-0063 sandbox edit-hint sc
8
14
  require "stringio" # StringIO — captures generator stdout during the sandbox run
9
15
  require "open3" # Open3.capture2e — captures `ruby -c` output+status for `lint`
10
16
  require_relative "port_takeover"
17
+ require_relative "env" # Tina4::Env.is_truthy -- resolve_config runs before lib/tina4 loads
11
18
 
12
19
  module Tina4
13
20
  class CLI
@@ -676,7 +683,7 @@ module Tina4
676
683
  # invokes PHP. That mismatch is why Ruby could not be launched through
677
684
  # the shared launcher the other frameworks use.
678
685
  opts.on("--managed", "Running under the tina4 CLI supervisor") { options[:managed] = true }
679
- opts.on("--production", "Use production server (Puma)") { options[:production] = true }
686
+ opts.on("--production", "Production mode: Puma if the app bundles it, else the built-in server") { options[:production] = true }
680
687
  opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
681
688
  opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
682
689
  opts.on("--no-kill", "Never take over the port from a stale dev server") { options[:no_kill] = true }
@@ -708,6 +715,11 @@ module Tina4
708
715
 
709
716
  require_relative "../tina4"
710
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
+
711
723
  root_dir = Dir.pwd
712
724
  Tina4.initialize!(root_dir)
713
725
 
@@ -727,9 +739,9 @@ module Tina4
727
739
 
728
740
  is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
729
741
 
730
- # Use Puma only when explicitly requested via --production flag
731
- # WEBrick is used for development (supports dev toolbar/reload)
732
- # Same production server, same shutdown contract as Tina4.run! - one
742
+ # Puma only with --production AND only when the app bundles it
743
+ # (ADR-0067); otherwise Tina4's built-in server, in development and
744
+ # production alike. Same production server, same shutdown contract as Tina4.run! - one
733
745
  # implementation, so the two entry points cannot drift again (the old
734
746
  # copy here claimed "we handle DB cleanup" and did no cleanup at all).
735
747
  # TINA4_DEFAULT_WEBSERVER=TRUE pins the built-in server even with
@@ -739,7 +751,6 @@ module Tina4
739
751
  return
740
752
  end
741
753
 
742
- Tina4::Log.info("Development server: WEBrick")
743
754
  server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
744
755
  server.start
745
756
  end
@@ -3806,7 +3817,7 @@ module Tina4
3806
3817
  # loopback, not 0.0.0.0. Only the DEFAULT changes: production
3807
3818
  # (TINA4_DEBUG off) keeps 0.0.0.0, and a developer who WANTS network
3808
3819
  # exposure sets TINA4_HOST=0.0.0.0 to override deliberately.
3809
- Tina4.truthy?(ENV["TINA4_DEBUG"]) ? "127.0.0.1" : DEFAULT_HOST
3820
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) ? "127.0.0.1" : DEFAULT_HOST
3810
3821
  end
3811
3822
  end
3812
3823
 
@@ -3875,9 +3886,18 @@ module Tina4
3875
3886
 
3876
3887
  # Gemfile
3877
3888
  unless File.exist?(File.join(dir, "Gemfile"))
3889
+ # Same Gemfile `tina4 init ruby` writes. The gem is published as
3890
+ # "tina4ruby" ("tina4-ruby" does not exist, so bundle install failed),
3891
+ # and sqlite3 is an app dependency (ADR-0067): tina4ruby loads it lazily
3892
+ # for the default SQLite database but never declares it.
3878
3893
  File.write(File.join(dir, "Gemfile"), <<~RUBY)
3879
3894
  source "https://rubygems.org"
3880
- gem "tina4-ruby", "~> 3.0"
3895
+
3896
+ gem "tina4ruby", "~> 3.0"
3897
+
3898
+ # SQLite driver for the default database (TINA4_DATABASE_URL=sqlite:...).
3899
+ # tina4ruby loads it lazily; it is an app dependency, like pg or mysql2.
3900
+ gem "sqlite3"
3881
3901
  RUBY
3882
3902
  end
3883
3903
 
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  # Tina4 Constants — HTTP status codes and content types.
2
8
  #
3
9
  # Standard constants for use in route handlers across all Tina4 frameworks.
@@ -59,25 +65,23 @@ module Tina4
59
65
  304 => "Not Modified", 307 => "Temporary Redirect", 308 => "Permanent Redirect",
60
66
  400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden",
61
67
  404 => "Not Found", 405 => "Method Not Allowed", 406 => "Not Acceptable",
68
+ 408 => "Request Timeout",
62
69
  409 => "Conflict", 410 => "Gone", 413 => "Content Too Large",
63
70
  415 => "Unsupported Media Type", 422 => "Unprocessable Content",
64
- 429 => "Too Many Requests",
71
+ 429 => "Too Many Requests", 431 => "Request Header Fields Too Large",
65
72
  500 => "Internal Server Error", 501 => "Not Implemented",
66
- 502 => "Bad Gateway", 503 => "Service Unavailable", 504 => "Gateway Timeout"
73
+ 502 => "Bad Gateway", 503 => "Service Unavailable", 504 => "Gateway Timeout",
74
+ 505 => "HTTP Version Not Supported"
67
75
  }.freeze
68
76
 
69
77
  # Return the canonical HTTP reason phrase for ``status``.
70
78
  #
71
79
  # Falls back to a sensible label when an exotic status is used. Never
72
80
  # returns an empty string — the HTTP/1.1 status line requires a phrase.
73
- # Prefers Rack::Utils::HTTP_STATUS_CODES when Rack is available so the
74
- # phrase tracks Rack's mapping, otherwise uses the local table above.
81
+ # The table is Tina4's own (the phrases match Python's and PHP's); it no
82
+ # longer defers to Rack::Utils, which is not a dependency.
75
83
  def self.http_reason(status)
76
84
  code = status.to_i
77
- if defined?(Rack::Utils::HTTP_STATUS_CODES)
78
- phrase = Rack::Utils::HTTP_STATUS_CODES[code]
79
- return phrase if phrase && !phrase.empty?
80
- end
81
85
  phrase = HTTP_REASON_PHRASES[code]
82
86
  return phrase if phrase && !phrase.empty?
83
87
  return "OK" if code >= 200 && code < 300
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # Lightweight dependency injection container.
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4 Ruby
4
10
  #
data/lib/tina4/context.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4 Ruby
4
10
  #
@@ -6,9 +12,10 @@
6
12
  #
7
13
  # Lets a Tina4 app ground its own AI assistant on its own source, offline: it
8
14
  # walks the project, chunks code on def/class/module boundaries and docs as
9
- # prose, and answers keyword/fuzzy queries over a SQLite FTS5 index. The
10
- # sqlite3 gem is already a Tina4-Ruby runtime dependency and modern SQLite ships
11
- # FTS5 + bm25() built in, so this adds NO new dependency.
15
+ # prose, and answers keyword/fuzzy queries over a SQLite FTS5 index. It uses the
16
+ # sqlite3 gem the APP declares (the `tina4 init ruby` Gemfile carries it; tina4ruby
17
+ # itself does not, ADR-0067) and modern SQLite ships FTS5 + bm25() built in, so
18
+ # this adds NO dependency. Without the gem the index reports itself unavailable.
12
19
  #
13
20
  # The retrieval core is a thin port of tina4-python's tina4_python/context
14
21
  # (itself the proven slice of neemee: SqliteFTS + the stable
@@ -29,6 +36,7 @@ require "pathname"
29
36
  require "fileutils"
30
37
  require "set"
31
38
  require_relative "context/chunker"
39
+ require_relative "sqlite3_gem"
32
40
 
33
41
  module Tina4
34
42
  class Context
@@ -70,7 +78,7 @@ module Tina4
70
78
  @available = !!check.call
71
79
  return unless @available
72
80
 
73
- require "sqlite3"
81
+ Tina4.require_sqlite3!
74
82
  parent = File.dirname(@path)
75
83
  FileUtils.mkdir_p(parent) unless parent.empty? || parent == "." || File.directory?(parent)
76
84
  # One connection guarded by a Mutex — the dev-MCP reload hook may run on a
@@ -82,7 +90,7 @@ module Tina4
82
90
  # ── FTS5 availability ───────────────────────────────────────
83
91
  # Whether this Ruby's sqlite3 build supports FTS5 (a real in-memory probe).
84
92
  def self.fts5_available?
85
- require "sqlite3"
93
+ Tina4.require_sqlite3!
86
94
  db = SQLite3::Database.new(":memory:")
87
95
  begin
88
96
  db.execute("CREATE VIRTUAL TABLE _probe USING fts5(x)")
data/lib/tina4/cors.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # CORS policy — reads config from env, computes the response headers.
@@ -37,6 +43,13 @@ module Tina4
37
43
  @warned = nil
38
44
  end
39
45
 
46
+ # The reasons warned about so far in this process - bounded by
47
+ # construction, never an origin (ADR-0048). Node's corsWarningReasons(),
48
+ # PHP's CorsMiddleware::warnedReasons().
49
+ def warned_reasons
50
+ (@warned || {}).keys.map(&:to_s)
51
+ end
52
+
40
53
  # The configured origins, split and emptied of blanks.
41
54
  def allowed_origins
42
55
  config[:origins].split(",").map(&:strip).reject(&:empty?)
@@ -56,8 +69,13 @@ module Tina4
56
69
  allowed = allowed_origins
57
70
  request_origin = env["HTTP_ORIGIN"]
58
71
 
72
+ # A same-origin request (browsers send Origin on every same-origin
73
+ # POST/PUT/PATCH/DELETE) is not a CORS request: it is never warned
74
+ # about. That ONLY silences the warning - it grants no CORS access.
75
+ cross_origin = present?(request_origin) && !same_origin?(env)
76
+
59
77
  if allowed.empty?
60
- warn_once(:unconfigured, request_origin) if present?(request_origin)
78
+ warn_once(:unconfigured, request_origin) if cross_origin
61
79
  return {}
62
80
  end
63
81
 
@@ -70,7 +88,7 @@ module Tina4
70
88
 
71
89
  origin = resolve_origin(env)
72
90
  if origin.nil?
73
- warn_once(:denied, request_origin) if present?(request_origin)
91
+ warn_once(:denied, request_origin) if cross_origin
74
92
  return headers
75
93
  end
76
94
 
@@ -150,6 +168,22 @@ module Tina4
150
168
  %w[true 1 yes].include?(config[:credentials].to_s.downcase)
151
169
  end
152
170
 
171
+ # Whether the request's Origin is the request's OWN origin
172
+ # (tina4-python #139). Own origin = scheme://host[:port], the scheme
173
+ # proxy-aware (Request.secure_scheme?, X-Forwarded-Proto first hop) and
174
+ # the host from X-Forwarded-Host, else Host. Both sides are normalised:
175
+ # lower-cased, default ports (http:80, https:443) dropped.
176
+ def same_origin?(env)
177
+ origin = normalize_origin(env["HTTP_ORIGIN"])
178
+ return false if origin.nil?
179
+
180
+ scheme = Tina4::Request.secure_scheme?(env) ? "https" : "http"
181
+ host = (env["HTTP_X_FORWARDED_HOST"] || env["HTTP_HOST"]).to_s.split(",").first.to_s.strip
182
+ return false if host.empty?
183
+
184
+ origin == normalize_origin("#{scheme}://#{host}")
185
+ end
186
+
153
187
  private
154
188
 
155
189
  def load_config
@@ -186,6 +220,20 @@ module Tina4
186
220
  !value.nil? && !value.to_s.empty?
187
221
  end
188
222
 
223
+ DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
224
+
225
+ # "scheme://host[:port]" lower-cased with a default port dropped, or nil
226
+ # for anything that is not an http(s) origin (e.g. "null").
227
+ def normalize_origin(value)
228
+ match = value.to_s.strip.chomp("/").match(%r{\A(https?)://([^/:\s]+|\[[^\]]+\])(?::(\d+))?\z}i)
229
+ return nil unless match
230
+
231
+ scheme = match[1].downcase
232
+ host = match[2].downcase
233
+ port = match[3]&.to_i
234
+ port.nil? || port == DEFAULT_PORTS[scheme] ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
235
+ end
236
+
189
237
  def merge_vary(current, field_name)
190
238
  parts = current.to_s.split(",").map(&:strip).reject(&:empty?)
191
239
  return parts.join(", ") if parts.any? { |p| p.casecmp?(field_name) }
@@ -193,17 +241,22 @@ module Tina4
193
241
  (parts + [field_name]).join(", ")
194
242
  end
195
243
 
196
- # Log an actionable warning at most once per reason per process.
244
+ # Log an actionable warning at most once per REASON per process.
197
245
  #
198
246
  # A rejected cross-origin request is otherwise invisible: the browser
199
247
  # reports a generic CORS failure and the server log says nothing, so the
200
248
  # operator has to read the framework source to find the env var to set.
249
+ #
250
+ # BOUNDED (ADR-0048): keyed by reason only, never by origin. Keying
251
+ # :denied by "denied:<origin>" gave every attacker-chosen Origin header its
252
+ # own @warned entry (never freed) and its own log line - an unbounded
253
+ # ledger and an unbounded log. The message still names the origin that
254
+ # triggered it, the first time per reason.
201
255
  def warn_once(reason, request_origin)
202
256
  @warned ||= {}
203
- key = reason == :denied ? "denied:#{request_origin}" : reason
204
- return if @warned[key]
257
+ return if @warned[reason]
205
258
 
206
- @warned[key] = true
259
+ @warned[reason] = true
207
260
  Tina4::Log.warning(warning_message(reason, request_origin))
208
261
  rescue StandardError
209
262
  nil # logging must never break a request
@@ -211,13 +264,16 @@ module Tina4
211
264
 
212
265
  def warning_message(reason, request_origin)
213
266
  case reason
267
+ # Neither message ever suggests the wildcard: allowing every website to
268
+ # silence one warning is the wrong fix. Each names the ONE origin to add.
214
269
  when :unconfigured
215
- "CORS: refused cross-origin request from #{request_origin} - no policy is configured. " \
216
- "Set TINA4_CORS_ORIGINS to the origins you want to allow, e.g. " \
217
- "TINA4_CORS_ORIGINS=https://app.example.com (or '*' to allow any origin)."
270
+ "CORS: cross-origin request from #{request_origin}, but no CORS policy is configured, " \
271
+ "so the browser will block this response. If that origin should be allowed, " \
272
+ "add it: TINA4_CORS_ORIGINS=#{request_origin}"
218
273
  when :denied
219
274
  "CORS: origin #{request_origin} is not in TINA4_CORS_ORIGINS (#{config[:origins]}) " \
220
- "- the browser will block this response."
275
+ "- the browser will block this response. If it should be allowed, add it: " \
276
+ "TINA4_CORS_ORIGINS=#{config[:origins]},#{request_origin}"
221
277
  else
222
278
  "CORS: TINA4_CORS_CREDENTIALS is true but TINA4_CORS_ORIGINS is '*'. The Fetch Standard " \
223
279
  "forbids Access-Control-Allow-Origin: * with credentials, so credentials are NOT being " \
data/lib/tina4/crud.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
 
4
10
  module Tina4
@@ -59,7 +65,7 @@ module Tina4
59
65
  query_params = request.respond_to?(:query) ? request.query : {}
60
66
  page = [(query_params["page"] || 1).to_i, 1].max
61
67
  search = query_params["search"].to_s.strip
62
- sort_col = query_params["sort"] || pk
68
+ sort_col = crud_sort_column(model, sql, query_params["sort"], pk)
63
69
  sort_dir = query_params["sort_dir"] == "desc" ? "desc" : "asc"
64
70
  offset = (page - 1) * limit
65
71
 
@@ -213,8 +219,8 @@ module Tina4
213
219
  # POST create
214
220
  Tina4::Router.add("POST", api_path, proc { |req, res|
215
221
  begin
216
- data = req.body_parsed
217
- result = db.insert(table_name, data)
222
+ data = table_column_attributes(db, table_name, req.body_parsed)
223
+ db.insert(table_name, data)
218
224
  res.json({ data: data, message: "Created" }, status: 201)
219
225
  rescue => e
220
226
  res.json({ error: e.message }, status: 500)
@@ -225,7 +231,8 @@ module Tina4
225
231
  Tina4::Router.add("PUT", "#{api_path}/{id}", proc { |req, res|
226
232
  begin
227
233
  id = req.params["id"]
228
- data = req.body_parsed
234
+ # The row is addressed by the URL id only, never by a body pk.
235
+ data = table_column_attributes(db, table_name, req.body_parsed, strip: pk)
229
236
  db.update(table_name, data, { pk => id })
230
237
  res.json({ data: data, message: "Updated" })
231
238
  rescue => e
@@ -245,6 +252,68 @@ module Tina4
245
252
  })
246
253
  end
247
254
 
255
+ # tina4: ADR-0069 - a SQL-mode write body is allow-listed against the
256
+ # table's REAL columns (matched case-insensitively, written in the
257
+ # introspected spelling); unknown keys are dropped, is_deleted is never
258
+ # client-writable, and +strip+ (the pk on update) is removed. Same write
259
+ # rule as AutoCrud (CRUD-DEC-02).
260
+ def table_column_attributes(db, table_name, data, strip: nil)
261
+ return {} unless data.is_a?(Hash)
262
+
263
+ columns = db.columns(table_name).to_h { |column| [column[:name].to_s.downcase, column[:name].to_s] }
264
+ blocked = ["is_deleted", strip.to_s.downcase]
265
+ data.each_with_object({}) do |(key, value), allowed|
266
+ column = columns[key.to_s.downcase]
267
+ next if column.nil? || blocked.include?(column.downcase)
268
+
269
+ allowed[column] = value
270
+ end
271
+ end
272
+
273
+ # tina4: ADR-0069 - ?sort reaches ORDER BY only as a column the source
274
+ # itself declares: a model's declared field (resolved to its DB column) or
275
+ # a column of the SQL query's own result set. Anything else falls back to
276
+ # the primary key - this is a rendered page, not an API, so no error.
277
+ def crud_sort_column(model, sql, requested, pk)
278
+ return pk if requested.nil? || requested.empty?
279
+ return model.resolve_field_column(requested) || pk if model
280
+
281
+ sql_result_columns(sql).include?(requested) ? requested : pk
282
+ end
283
+
284
+ # The query with any ORDER BY / LIMIT clause removed, then stripped.
285
+ #
286
+ # Line by line with plain string operations - a case-insensitive search
287
+ # for the keyword, then a cut to the end of that line - so it is linear on
288
+ # any input (the regex it replaces was flagged as polynomial by CodeQL).
289
+ # Same result: a keyword is removed with the rest of its line only when at
290
+ # least one character follows it, and the line break is kept.
291
+ def strip_order_and_limit(sql)
292
+ sql.to_s.each_line.map do |line|
293
+ cut_from_keyword(cut_from_keyword(line, /ORDER BY /i), /LIMIT /i)
294
+ end.join.strip
295
+ end
296
+
297
+ # +keyword+ is a fixed literal with no quantifier, so the search is linear.
298
+ def cut_from_keyword(line, keyword)
299
+ ending = line.end_with?("\n") ? "\n" : ""
300
+ content = ending.empty? ? line : line[0...-1]
301
+ at = content.index(keyword)
302
+ return line if at.nil? || content.length <= at + keyword.source.length
303
+
304
+ content[0...at] + ending
305
+ end
306
+
307
+ # Column names of the SQL query's result set, read from one probe row.
308
+ def sql_result_columns(sql)
309
+ db = Tina4.database
310
+ return [] unless db
311
+
312
+ base = strip_order_and_limit(sql)
313
+ row = db.fetch("SELECT * FROM (#{base}) AS _crud_sub", [], limit: 1).first
314
+ row ? row.keys.map(&:to_s) : []
315
+ end
316
+
248
317
  # Fetch data using an ORM model class
249
318
  def fetch_model_data(model, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
250
319
  order_by = "#{sort} #{sort_dir.upcase}"
@@ -280,12 +349,12 @@ module Tina4
280
349
  return [[], 0] unless db
281
350
 
282
351
  # Wrap the original SQL for sorting
283
- query = sql.gsub(/ORDER BY .+$/i, "").gsub(/LIMIT .+$/i, "").strip
284
- query += " ORDER BY #{sort} #{sort_dir.upcase}"
352
+ base = strip_order_and_limit(sql)
353
+ query = "#{base} ORDER BY #{sort} #{sort_dir.upcase}"
285
354
 
286
355
  if !search.empty?
287
356
  # Wrap in a subquery to add search filtering
288
- wrapped = "SELECT * FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_sub WHERE "
357
+ wrapped = "SELECT * FROM (#{base}) AS _crud_sub WHERE "
289
358
  columns = extract_columns(sql)
290
359
  search_parts = columns.map { |col| "CAST(#{col} AS TEXT) LIKE ?" }
291
360
  wrapped += search_parts.join(" OR ")
@@ -293,14 +362,14 @@ module Tina4
293
362
  params = columns.map { "%#{search}%" }
294
363
 
295
364
  # Get total count
296
- count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
365
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{base}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
297
366
  count_result = db.fetch_one(count_sql, params)
298
367
  total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
299
368
 
300
369
  results = db.fetch(wrapped, params, limit: limit, offset: offset)
301
370
  else
302
371
  # Get total count
303
- count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt"
372
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{base}) AS _crud_cnt"
304
373
  count_result = db.fetch_one(count_sql)
305
374
  total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
306
375