tina4ruby 3.13.137 → 3.13.138

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -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 +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  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 +6 -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 +6 -0
  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 +6 -0
  26. data/lib/tina4/cli.rb +22 -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 +215 -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 +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  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 +196 -56
  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 +28 -27
  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 +6 -0
  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 +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  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 +9 -3
  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 +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  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 +6 -0
  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 +85 -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 +6 -0
  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 +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
data/lib/tina4/auth.rb CHANGED
@@ -1,9 +1,17 @@
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 "openssl"
3
- require "base64"
9
+ require_relative "base64"
4
10
  require "json"
5
11
  require "fileutils"
6
12
  require "securerandom"
13
+ require_relative "parse_json"
14
+ require_relative "secret_file"
7
15
 
8
16
  module Tina4
9
17
  module Auth
@@ -100,12 +108,10 @@ module Tina4
100
108
  local_path = File.join(root_dir, ".env.local")
101
109
  # If the file exists and does not end in a newline, prepend one so the
102
110
  # new key lands on its own line rather than gluing onto the last value.
103
- prefix = ""
104
- if File.exist?(local_path)
105
- content = File.read(local_path)
106
- prefix = "\n" if !content.empty? && !content.end_with?("\n")
111
+ SecretFile.update(local_path) do |content|
112
+ prefix = !content.empty? && !content.end_with?("\n") ? "\n" : ""
113
+ content + "#{prefix}TINA4_SECRET=#{new_secret}\n"
107
114
  end
108
- File.open(local_path, "a") { |f| f.write("#{prefix}TINA4_SECRET=#{new_secret}\n") }
109
115
  log_info("Auth: generated a development secret, saved to .env.local (gitignored)")
110
116
  rescue StandardError => e
111
117
  # Keep the in-memory secret for this run; just warn. Never crash boot.
@@ -139,7 +145,7 @@ module Tina4
139
145
 
140
146
  # Base64url-encode without padding (JWT spec)
141
147
  def base64url_encode(data)
142
- Base64.urlsafe_encode64(data, padding: false)
148
+ Tina4::Base64.urlsafe_encode64(data, padding: false)
143
149
  end
144
150
 
145
151
  # Base64url-decode (handles missing padding)
@@ -147,7 +153,7 @@ module Tina4
147
153
  # Add back padding
148
154
  remainder = str.length % 4
149
155
  str += "=" * ((4 - remainder) % 4) if remainder != 0
150
- Base64.urlsafe_decode64(str)
156
+ Tina4::Base64.urlsafe_decode64(str)
151
157
  end
152
158
 
153
159
  # Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else
@@ -200,10 +206,10 @@ module Tina4
200
206
  def decode_envelope(token, algorithm)
201
207
  parts = token.to_s.split(".")
202
208
  return nil unless parts.length == 3
203
- return nil unless JSON.parse(base64url_decode(parts[0]))["alg"] == algorithm
209
+ return nil unless Tina4.parse_json(base64url_decode(parts[0]))["alg"] == algorithm
204
210
  return nil unless yield("#{parts[0]}.#{parts[1]}", base64url_decode(parts[2]))
205
211
 
206
- payload = JSON.parse(base64url_decode(parts[1]))
212
+ payload = Tina4.parse_json(base64url_decode(parts[1]))
207
213
  now = Time.now.to_i
208
214
 
209
215
  # RFC 7519 s4.1.4: "The processing of the 'exp' claim requires that the
@@ -361,7 +367,7 @@ module Tina4
361
367
  return nil unless parts.length == 3
362
368
 
363
369
  payload_json = base64url_decode(parts[1])
364
- JSON.parse(payload_json)
370
+ Tina4.parse_json(payload_json)
365
371
  rescue ArgumentError, JSON::ParserError
366
372
  nil
367
373
  end
@@ -564,7 +570,7 @@ module Tina4
564
570
  def generate_keys
565
571
  Tina4::Log.info("Generating RSA key pair for JWT authentication")
566
572
  key = OpenSSL::PKey::RSA.generate(2048)
567
- File.write(private_key_path, key.to_pem)
573
+ SecretFile.update(private_key_path) { key.to_pem }
568
574
  File.write(public_key_path, key.public_key.to_pem)
569
575
  @private_key = nil
570
576
  @public_key = nil
@@ -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
@@ -105,17 +111,46 @@ module Tina4
105
111
  page = [page, 1].max
106
112
  limit = per_page
107
113
  offset = req.query["offset"] ? req.query["offset"].to_i : (page - 1) * per_page
108
- order_by = parse_sort(req.query["sort"])
109
114
 
110
- # Filter support: ?filter[field]=value
115
+ # Filter support: ?filter[field]=value. ADR-0069: ANY bracket key is
116
+ # captured and must resolve to a declared field (attribute or its
117
+ # mapped column); an unknown key is a 400 before any SQL runs.
118
+ #
119
+ # The query parser keeps bracketed keys flat, so a list or map shows
120
+ # up as "sort[...]" or "filter[name][...]": a 400, never ignored.
121
+ if req.query.keys.any? { |key| key.start_with?("sort[") }
122
+ next res.error("INVALID_QUERY_PARAMETER",
123
+ "Query parameter 'sort' must be a single comma-separated string", 400)
124
+ end
125
+
111
126
  filter_conditions = []
112
127
  filter_values = []
128
+ unknown_filter_field = nil
129
+ nested_filter_field = nil
113
130
  req.query.each do |key, value|
114
- if key =~ /\Afilter\[(\w+)\]\z/
115
- filter_conditions << "#{$1} = ?"
116
- filter_values << value
131
+ next unless key =~ /\Afilter\[(.*)\]\z/m
132
+
133
+ filter_key = Regexp.last_match(1)
134
+ if filter_key.include?("][")
135
+ nested_filter_field = filter_key.split("][", 2).first
136
+ break
117
137
  end
138
+ column = model_class.resolve_field_column(filter_key)
139
+ unless column
140
+ unknown_filter_field = filter_key
141
+ break
142
+ end
143
+ filter_conditions << "#{column} = ?"
144
+ filter_values << value
145
+ end
146
+ if nested_filter_field
147
+ next res.error("INVALID_QUERY_PARAMETER",
148
+ "Filter value for '#{nested_filter_field}' must be a single value", 400)
118
149
  end
150
+ next res.error("UNKNOWN_FIELD", "Unknown filter field '#{unknown_filter_field}'", 400) if unknown_filter_field
151
+
152
+ order_by, unknown_sort_field = parse_sort(model_class, req.query["sort"])
153
+ next res.error("UNKNOWN_FIELD", "Unknown sort field '#{unknown_sort_field}'", 400) if unknown_sort_field
119
154
 
120
155
  if filter_conditions.empty?
121
156
  records = model_class.all(limit: limit, offset: offset, order_by: order_by)
@@ -150,8 +185,7 @@ module Tina4
150
185
  # GET /api/{table}/{id} -- get single record
151
186
  Tina4::Router.add("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
152
187
  begin
153
- id = req.params["id"]
154
- record = model_class.find_by_id(id.to_i)
188
+ record = find_addressed_record(model_class, req.params["id"])
155
189
  if record
156
190
  res.json({ data: record.to_h })
157
191
  else
@@ -200,8 +234,7 @@ module Tina4
200
234
  # PUT /api/{table}/{id} -- update record
201
235
  put_route = Tina4::Router.add("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
202
236
  begin
203
- id = req.params["id"]
204
- record = model_class.find_by_id(id.to_i)
237
+ record = find_addressed_record(model_class, req.params["id"])
205
238
  unless record
206
239
  next res.json({ error: "Not found" }, status: 404)
207
240
  end
@@ -236,8 +269,7 @@ module Tina4
236
269
  # DELETE /api/{table}/{id} -- delete record
237
270
  delete_route = Tina4::Router.add("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
238
271
  begin
239
- id = req.params["id"]
240
- record = model_class.find_by_id(id.to_i)
272
+ record = find_addressed_record(model_class, req.params["id"])
241
273
  unless record
242
274
  next res.json({ error: "Not found" }, status: 404)
243
275
  end
@@ -314,27 +346,51 @@ module Tina4
314
346
  auto_increment = single_pk && defs[single_pk.to_sym] && defs[single_pk.to_sym][:auto_increment]
315
347
  strip_pk = is_create ? !(single_pk && !auto_increment) : true
316
348
 
349
+ # ADR-0069: a body key is written only when it resolves to a DECLARED
350
+ # field, by attribute name or its mapped column - the SAME resolver the
351
+ # list route's filter/sort use. Anything else is dropped (writes drop,
352
+ # reads 400).
317
353
  data.each_with_object({}) do |(key, value), allowed|
318
- key_s = key.to_s
319
- next unless defs.key?(key_s.to_sym)
320
- next if key_s == "is_deleted"
321
- next if strip_pk && pk_fields.include?(key_s)
354
+ attribute = model_class.resolve_field(key)
355
+ next unless attribute
322
356
 
323
- allowed[key] = value
357
+ attribute_name = attribute.to_s
358
+ next if attribute_name == "is_deleted"
359
+ next if strip_pk && pk_fields.include?(attribute_name)
360
+
361
+ allowed[attribute_name] = value
324
362
  end
325
363
  end
326
364
 
327
- # Parse sort parameter: "-name,created_at" => "name DESC, created_at ASC"
328
- def parse_sort(sort_str)
329
- return nil if sort_str.nil? || sort_str.empty?
330
- sort_str.split(",").map do |field|
331
- field = field.strip
332
- if field.start_with?("-")
333
- "#{field[1..-1]} DESC"
334
- else
335
- "#{field} ASC"
336
- end
337
- end.join(", ")
365
+ # The row a URL {id} addresses, or nil. The id is bound, never
366
+ # interpolated; ORM.coerce_primary_key keeps a string natural key as-is
367
+ # and answers nil for a non-integer id on an integer key (-> 404).
368
+ def find_addressed_record(model_class, raw_id)
369
+ id = model_class.coerce_primary_key(raw_id)
370
+ id.nil? ? nil : model_class.find_by_id(id)
371
+ end
372
+
373
+ # Parse the sort parameter: "-name,created_at" => "name DESC, created_at ASC".
374
+ #
375
+ # ADR-0069: each part (empty parts skipped, a leading "-" means DESC) must
376
+ # resolve to a declared field; the ORDER BY is built ONLY from resolved
377
+ # columns plus ASC/DESC. Returns [order_by_or_nil, unknown_field_or_nil].
378
+ def parse_sort(model_class, sort_str)
379
+ return [nil, nil] if sort_str.nil? || sort_str.empty?
380
+
381
+ parts = []
382
+ sort_str.split(",").each do |part|
383
+ part = part.strip
384
+ next if part.empty?
385
+
386
+ descending = part.start_with?("-")
387
+ field = descending ? part[1..].strip : part
388
+ column = model_class.resolve_field_column(field)
389
+ return [nil, field] unless column
390
+
391
+ parts << "#{column} #{descending ? 'DESC' : 'ASC'}"
392
+ end
393
+ [parts.empty? ? nil : parts.join(", "), nil]
338
394
  end
339
395
  end
340
396
  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
  module Tina4
4
10
  # Periodic background task registry.
@@ -9,7 +15,7 @@ module Tina4
9
15
  #
10
16
  # Ruby has no asyncio event loop, so each task runs in its own dedicated OS
11
17
  # thread, started at registration time. Because the thread runs regardless of
12
- # which web server (Puma/WEBrick) is in front, a Ruby background task is never
18
+ # which web server (the built-in server or Puma) is in front, a Ruby background task is never
13
19
  # a silent no-op under production — the thread IS the runtime (contrast the
14
20
  # Python ASGI / PHP-FPM silent-no-op the other frameworks had to fix). The GIL
15
21
  # keeps it cooperative-enough for the periodic work this is meant for (queue
@@ -0,0 +1,59 @@
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
+
8
+
9
+ module Tina4
10
+ # Base64 on core Array#pack / String#unpack1, so Tina4 needs no base64 gem.
11
+ #
12
+ # The base64 gem stopped being a default gem in Ruby 3.4, and a bundled gem an
13
+ # app does not declare is not on the load path under Bundler. The gem is itself
14
+ # a thin wrapper over pack("m"), so this module keeps its method names and its
15
+ # exact semantics (same bytes out, same ArgumentError on bad strict input) and
16
+ # the call sites read the same with a Tina4:: prefix.
17
+ module Base64
18
+ module_function
19
+
20
+ # RFC 2045 (MIME): a newline after every 60 encoded characters and at the end.
21
+ def encode64(binary)
22
+ [binary].pack("m")
23
+ end
24
+
25
+ # Lenient decode: characters outside the alphabet are skipped, never raised on.
26
+ def decode64(string)
27
+ string.unpack1("m")
28
+ end
29
+
30
+ # RFC 4648: no line feeds.
31
+ def strict_encode64(binary)
32
+ [binary].pack("m0")
33
+ end
34
+
35
+ # Raises ArgumentError on anything that is not canonical, padded base64.
36
+ def strict_decode64(string)
37
+ string.unpack1("m0")
38
+ end
39
+
40
+ # RFC 4648 URL-safe alphabet ("-" and "_"), padding optional.
41
+ def urlsafe_encode64(binary, padding: true)
42
+ encoded = strict_encode64(binary)
43
+ encoded.chomp!("==") or encoded.chomp!("=") unless padding
44
+ encoded.tr!("+/", "-_")
45
+ encoded
46
+ end
47
+
48
+ # Accepts padded or unpadded input; raises ArgumentError on invalid input.
49
+ def urlsafe_decode64(string)
50
+ if !string.end_with?("=") && string.length % 4 != 0
51
+ string = string.ljust((string.length + 3) & ~3, "=")
52
+ string.tr!("-_", "+/")
53
+ else
54
+ string = string.tr("-_", "+/")
55
+ end
56
+ strict_decode64(string)
57
+ end
58
+ end
59
+ end
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
@@ -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"
@@ -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"
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 }
@@ -727,9 +734,9 @@ module Tina4
727
734
 
728
735
  is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
729
736
 
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
737
+ # Puma only with --production AND only when the app bundles it
738
+ # (ADR-0067); otherwise Tina4's built-in server, in development and
739
+ # production alike. Same production server, same shutdown contract as Tina4.run! - one
733
740
  # implementation, so the two entry points cannot drift again (the old
734
741
  # copy here claimed "we handle DB cleanup" and did no cleanup at all).
735
742
  # TINA4_DEFAULT_WEBSERVER=TRUE pins the built-in server even with
@@ -739,7 +746,6 @@ module Tina4
739
746
  return
740
747
  end
741
748
 
742
- Tina4::Log.info("Development server: WEBrick")
743
749
  server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
744
750
  server.start
745
751
  end
@@ -3806,7 +3812,7 @@ module Tina4
3806
3812
  # loopback, not 0.0.0.0. Only the DEFAULT changes: production
3807
3813
  # (TINA4_DEBUG off) keeps 0.0.0.0, and a developer who WANTS network
3808
3814
  # exposure sets TINA4_HOST=0.0.0.0 to override deliberately.
3809
- Tina4.truthy?(ENV["TINA4_DEBUG"]) ? "127.0.0.1" : DEFAULT_HOST
3815
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) ? "127.0.0.1" : DEFAULT_HOST
3810
3816
  end
3811
3817
  end
3812
3818
 
@@ -3875,9 +3881,18 @@ module Tina4
3875
3881
 
3876
3882
  # Gemfile
3877
3883
  unless File.exist?(File.join(dir, "Gemfile"))
3884
+ # Same Gemfile `tina4 init ruby` writes. The gem is published as
3885
+ # "tina4ruby" ("tina4-ruby" does not exist, so bundle install failed),
3886
+ # and sqlite3 is an app dependency (ADR-0067): tina4ruby loads it lazily
3887
+ # for the default SQLite database but never declares it.
3878
3888
  File.write(File.join(dir, "Gemfile"), <<~RUBY)
3879
3889
  source "https://rubygems.org"
3880
- gem "tina4-ruby", "~> 3.0"
3890
+
3891
+ gem "tina4ruby", "~> 3.0"
3892
+
3893
+ # SQLite driver for the default database (TINA4_DATABASE_URL=sqlite:...).
3894
+ # tina4ruby loads it lazily; it is an app dependency, like pg or mysql2.
3895
+ gem "sqlite3"
3881
3896
  RUBY
3882
3897
  end
3883
3898
 
@@ -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)")