tina4ruby 3.13.39 → 3.13.40

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.
data/lib/tina4/env.rb CHANGED
@@ -62,8 +62,13 @@ module Tina4
62
62
  end
63
63
  lines.concat([
64
64
  "",
65
- "Run `tina4 env --migrate` to rewrite your .env automatically,",
66
- "or rename manually. See https://tina4.com/release/3.12.0",
65
+ "Note: these may come from a .env file loaded by dotenv, not just",
66
+ "the runtime environment - check your image / build context (a .env",
67
+ "baked into a Docker image is loaded at startup) as well as k8s/CI env.",
68
+ "",
69
+ "FIX: run `tina4 env --migrate` to rewrite your .env automatically",
70
+ "(it renames every legacy name to its TINA4_ form in place).",
71
+ "Or rename manually. See https://tina4.com/release/3.12.0",
67
72
  "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
68
73
  sep, ""
69
74
  ])
data/lib/tina4/mcp.rb CHANGED
@@ -131,38 +131,71 @@ module Tina4
131
131
  schema
132
132
  end
133
133
 
134
- # Check if the server is running on localhost.
134
+ # Informational only — whether the CONFIGURED host looks local.
135
+ #
136
+ # NOT the security gate. This reads TINA4_HOST_NAME (the configured bind
137
+ # address), which on a 0.0.0.0 bind looks "local" while still accepting
138
+ # remote clients. Trust decisions use {request_allowed?} with the RAW
139
+ # socket peer instead. Kept for diagnostics / back-compat.
135
140
  def self.is_localhost?
136
141
  host = ENV.fetch("TINA4_HOST_NAME", "localhost:7145").split(":").first
137
142
  ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
138
143
  end
139
144
 
140
- # Resolve whether the built-in MCP dev server should be active.
145
+ # Whether an address is a loopback (in-process / same-host) peer.
146
+ #
147
+ # Operates on the RAW socket peer, never X-Forwarded-For. Empty means an
148
+ # in-process / synthetic request (no socket) and is trusted. The `::ffff:`
149
+ # IPv4-mapped prefix is stripped. NOTE: 0.0.0.0 is a BIND address, never a
150
+ # client address, so it is deliberately NOT loopback.
141
151
  #
142
- # Resolution order (highest priority first):
143
- # 1. TINA4_MCP set explicitly → use that (truthy/falsey). Honoured on ANY
144
- # host. An explicit `true` is how a sysadmin opts a remote /
145
- # debug-disabled deployment in (e.g. for a remote AI assistant); an
146
- # explicit `false` force-disables it everywhere.
147
- # 2. TINA4_DEBUG=true implicit on for dev, but LOCALHOST-ONLY unless
148
- # TINA4_MCP_REMOTE=true. The MCP dev tools expose powerful operations
149
- # (DB query, file read/WRITE, route listing), so they never auto-expose
150
- # on a non-localhost host without an explicit opt-in.
152
+ # Python master parity: tina4_python.mcp.is_loopback.
153
+ def self.is_loopback?(ip)
154
+ return true if ip.nil? || ip.to_s.empty?
155
+
156
+ addr = ip.to_s.strip.downcase
157
+ addr = addr[7..] if addr.start_with?("::ffff:")
158
+ addr == "::1" || addr == "localhost" || addr.start_with?("127.")
159
+ end
160
+
161
+ # Capability gate — whether the MCP subsystem may run at all.
162
+ #
163
+ # Pure capability, host-INDEPENDENT (Python master parity):
164
+ # 1. TINA4_MCP set explicitly → use it (sysadmin override, any host).
165
+ # 2. Else TINA4_DEBUG truthy → MCP is a capability of this deployment.
151
166
  # 3. Otherwise off.
152
167
  #
153
- # Mirrors the Python master (tina4_python/mcp/__init__.py is_enabled). Before
154
- # v3.13.39 is_localhost? was dead code and TINA4_MCP_REMOTE was never read, so
155
- # the documented localhost guard was not actually enforced a non-localhost
156
- # TINA4_DEBUG=true deployment auto-exposed the dev tools. This wires it.
168
+ # This NO LONGER consults the host. A debug box bound to 0.0.0.0 still "has"
169
+ # the capability, but {request_allowed?} is what decides whether a given
170
+ # CALLER may use it loopback always, remote only with an explicit opt-in
171
+ # plus a valid token. Splitting capability from per-request authorisation
172
+ # closes the hole where a 0.0.0.0 bind auto-exposed DB/file tools to remote
173
+ # unauthenticated callers (pre-3.13.40 is_localhost? treated 0.0.0.0 local).
157
174
  def self.mcp_enabled?
158
175
  explicit = ENV["TINA4_MCP"]
159
176
  if explicit && !explicit.empty?
160
177
  return truthy?(explicit)
161
178
  end
162
- return false unless truthy?(ENV["TINA4_DEBUG"])
163
179
 
164
- # Dev auto-enable: localhost only, unless explicitly opted into remote.
165
- is_localhost? || truthy?(ENV["TINA4_MCP_REMOTE"])
180
+ truthy?(ENV["TINA4_DEBUG"])
181
+ end
182
+
183
+ # Per-request authorisation — whether THIS caller may use MCP.
184
+ #
185
+ # @param remote_ip [String] raw socket peer (env["REMOTE_ADDR"]), never XFF.
186
+ # @param has_valid_token [Boolean] true when the request carried a token
187
+ # matching TINA4_MCP_TOKEN.
188
+ #
189
+ # Rules (Python master parity, tina4_python.mcp.is_request_allowed):
190
+ # - Capability off ({mcp_enabled?} false) → deny.
191
+ # - Loopback peer → allow.
192
+ # - Remote peer → only when TINA4_MCP_REMOTE is truthy AND a valid token
193
+ # was presented. No configured token ⇒ remote can never pass.
194
+ def self.request_allowed?(remote_ip, has_valid_token: false)
195
+ return false unless mcp_enabled?
196
+ return true if is_loopback?(remote_ip)
197
+
198
+ truthy?(ENV["TINA4_MCP_REMOTE"]) && has_valid_token
166
199
  end
167
200
 
168
201
  # Case-insensitive truthiness for env values: true/1/yes/on.
@@ -593,9 +626,27 @@ module Tina4
593
626
  err = looks_like_prose.call(rel_path)
594
627
  raise ArgumentError, "Invalid path #{rel_path.inspect}: #{err}" if err
595
628
  resolved = File.expand_path(rel_path, project_root)
596
- unless resolved.start_with?(project_root)
629
+ # Compare against root + separator, not a bare prefix: a plain
630
+ # start_with?(project_root) would also accept a sibling like
631
+ # "<root>-evil". expand_path already resolves ".." so a climb-out
632
+ # lands outside root and is rejected here.
633
+ root_prefix = "#{project_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
634
+ unless resolved == project_root || resolved.start_with?(root_prefix)
597
635
  raise ArgumentError, "Path escapes project directory: #{rel_path}"
598
636
  end
637
+ # Belt-and-braces against symlink escapes: if the path already exists,
638
+ # canonicalise it and re-check containment. A symlink inside the tree
639
+ # pointing outside would otherwise slip past the textual check. New
640
+ # paths (parent not created yet) have no realpath and rely on the
641
+ # expand_path containment above.
642
+ if File.exist?(resolved)
643
+ real = File.realpath(resolved)
644
+ real_root = File.realpath(project_root)
645
+ real_prefix = "#{real_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
646
+ unless real == real_root || real.start_with?(real_prefix)
647
+ raise ArgumentError, "Path escapes project directory: #{rel_path}"
648
+ end
649
+ end
599
650
  resolved
600
651
  end
601
652
 
@@ -663,7 +714,22 @@ module Tina4
663
714
  db = Tina4.database
664
715
  return { "error" => "No database connection" } if db.nil?
665
716
  param_list = params.is_a?(String) ? JSON.parse(params) : params
666
- result = db.fetch(sql, param_list)
717
+ param_list = [] unless param_list.is_a?(Array)
718
+ # Defense-in-depth: this tool is read-only. Strip comments, reject
719
+ # multiple statements, and require a leading SELECT/WITH so it can
720
+ # never mutate data even if reached (database_execute is the write
721
+ # surface, gated separately). Mirrors the Python master.
722
+ cleaned = sql.to_s.gsub(/--[^\r\n]*/, " ").gsub(%r{/\*.*?\*/}m, " ")
723
+ cleaned = cleaned.strip.sub(/[;\s]+\z/, "")
724
+ return { "error" => "database_query rejects multiple statements" } if cleaned.include?(";")
725
+ unless cleaned =~ /\A(select|with)\b/i
726
+ return { "error" => "database_query is read-only (SELECT/WITH only)" }
727
+ end
728
+ begin
729
+ result = db.fetch(cleaned, param_list)
730
+ rescue => e
731
+ return { "error" => (db.get_error rescue nil) || e.message }
732
+ end
667
733
  { "records" => result.to_a, "count" => result.count }
668
734
  }, "Execute a read-only SQL query (SELECT)")
669
735
 
@@ -692,6 +758,12 @@ module Tina4
692
758
  server.register_tool("database_columns", lambda { |table:|
693
759
  db = Tina4.database
694
760
  return { "error" => "No database connection" } if db.nil?
761
+ # Constrain the table name to a safe identifier (optionally
762
+ # schema-qualified) — defense-in-depth so it can never be abused for
763
+ # injection even if an adapter interpolates it. Parity with Python/PHP.
764
+ unless table.to_s =~ /\A[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?\z/
765
+ return { "error" => "Invalid table name" }
766
+ end
695
767
  db.columns(table)
696
768
  }, "Get column definitions for a table")
697
769
 
@@ -454,17 +454,26 @@ module Tina4
454
454
  end
455
455
 
456
456
  def serve_swagger_ui
457
+ # Honour the documented production on/off switch (TINA4_SWAGGER_ENABLED,
458
+ # else TINA4_DEBUG) — before v3.13.40 this was never checked and /swagger
459
+ # (the full API surface) was served unconditionally in production.
460
+ return [404, { "content-type" => "text/plain" }, ["Not Found"]] unless Tina4::Swagger.enabled?
461
+
462
+ # The UI assets load from a CDN by default (keeps the framework
463
+ # zero-dependency — no vendored ~1.4MB swagger-ui-dist). Air-gapped
464
+ # deployments point TINA4_SWAGGER_UI_CDN at a self-hosted mirror.
465
+ cdn = (ENV["TINA4_SWAGGER_UI_CDN"] || "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").sub(%r{/+\z}, "")
457
466
  html = <<~HTML
458
467
  <!DOCTYPE html>
459
468
  <html lang="en">
460
469
  <head>
461
470
  <meta charset="UTF-8">
462
471
  <title>API Documentation</title>
463
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
472
+ <link rel="stylesheet" href="#{cdn}/swagger-ui.css">
464
473
  </head>
465
474
  <body>
466
475
  <div id="swagger-ui"></div>
467
- <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
476
+ <script src="#{cdn}/swagger-ui-bundle.js"></script>
468
477
  <script>
469
478
  SwaggerUIBundle({ url: '/swagger/openapi.json', dom_id: '#swagger-ui' });
470
479
  </script>
@@ -475,8 +484,11 @@ module Tina4
475
484
  end
476
485
 
477
486
  def serve_openapi_json
478
- @openapi_json ||= JSON.generate(Tina4::Swagger.generate)
479
- [200, { "content-type" => "application/json; charset=utf-8" }, [@openapi_json]]
487
+ return [404, { "content-type" => "application/json; charset=utf-8" }, ['{"error":"Not Found"}']] unless Tina4::Swagger.enabled?
488
+
489
+ # Regenerate per request — DevReload adds routes in-process (same PID), so
490
+ # a memoized spec would go stale until restart. Swagger.generate is cheap.
491
+ [200, { "content-type" => "application/json; charset=utf-8" }, [JSON.generate(Tina4::Swagger.generate)]]
480
492
  end
481
493
 
482
494
  def handle_403(path = "")
data/lib/tina4/swagger.rb CHANGED
@@ -7,14 +7,28 @@ module Tina4
7
7
  def generate(routes = [])
8
8
  spec = base_spec
9
9
  route_list = routes.empty? ? Tina4::Router.routes : routes
10
+ # Accumulators shared across routes: ORM models referenced
11
+ # (-> components.schemas), tags used (-> top-level tags[]), seen
12
+ # operationIds (de-dup — OpenAPI requires them unique).
13
+ ctx = { models: {}, used_tags: [], seen_ids: [] }
10
14
  route_list.each do |route|
11
- add_route_to_spec(spec, route)
15
+ add_route_to_spec(spec, route, ctx)
12
16
  end
17
+
18
+ unless ctx[:models].empty?
19
+ spec["components"]["schemas"] = {}
20
+ ctx[:models].each do |name, klass|
21
+ spec["components"]["schemas"][name] = model_schema(klass)
22
+ end
23
+ end
24
+ spec["tags"] = ctx[:used_tags].map { |t| { "name" => t } } unless ctx[:used_tags].empty?
25
+
13
26
  spec
14
27
  end
15
28
 
16
- # TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. When false, callers
17
- # can choose to skip mounting /swagger entirely in production.
29
+ # TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. Wired into RackApp's
30
+ # /swagger serving (v3.13.40), so it genuinely gates whether the docs are
31
+ # served — it was dead code before.
18
32
  def enabled?
19
33
  explicit = ENV["TINA4_SWAGGER_ENABLED"]
20
34
  if explicit && !explicit.empty?
@@ -51,9 +65,7 @@ module Tina4
51
65
  {
52
66
  "openapi" => "3.0.3",
53
67
  "info" => info,
54
- "servers" => [
55
- { "url" => "/" }
56
- ],
68
+ "servers" => servers,
57
69
  "paths" => {},
58
70
  "components" => {
59
71
  "securitySchemes" => {
@@ -67,48 +79,125 @@ module Tina4
67
79
  }
68
80
  end
69
81
 
70
- def add_route_to_spec(spec, route)
71
- path = convert_path(route.path)
82
+ # servers[] — TINA4_SWAGGER_SERVERS (comma-separated) for a multi-server
83
+ # list, else SWAGGER_DEV_URL, else the relative "/" default.
84
+ def servers
85
+ raw = ENV.fetch("TINA4_SWAGGER_SERVERS", "")
86
+ urls = raw.split(",").map(&:strip).reject(&:empty?)
87
+ return urls.map { |u| { "url" => u } } unless urls.empty?
88
+
89
+ dev = ENV["SWAGGER_DEV_URL"]
90
+ return [{ "url" => dev }] if dev && !dev.empty?
91
+
92
+ [{ "url" => "/" }]
93
+ end
94
+
95
+ # Valid OpenAPI path-item methods. Anything else (e.g. "any", a WebSocket
96
+ # "ws") is not a valid key and would make the document spec-invalid.
97
+ HTTP_METHODS = %w[get post put patch delete head options trace].freeze
98
+
99
+ def add_route_to_spec(spec, route, ctx)
72
100
  method = route.method.downcase
73
- return if method == "any"
101
+ return unless HTTP_METHODS.include?(method)
102
+
103
+ path = convert_path(route.path)
104
+ meta = route.swagger_meta || {}
105
+
106
+ # ORM model -> components.schemas + $ref
107
+ ref = nil
108
+ if (model = meta[:model])
109
+ klass = model.is_a?(String) ? resolve_model(model) : model
110
+ if klass
111
+ name = klass.name.split("::").last
112
+ ctx[:models][name] ||= klass
113
+ ref = "#/components/schemas/#{name}"
114
+ end
115
+ end
116
+
117
+ tags = meta[:tags] || [extract_tag(route.path)]
118
+ tags.each { |t| ctx[:used_tags] << t unless ctx[:used_tags].include?(t) }
74
119
 
75
120
  spec["paths"][path] ||= {}
76
121
  operation = {
77
- "summary" => route.swagger_meta[:summary] || "#{method.upcase} #{route.path}",
78
- "description" => route.swagger_meta[:description] || "",
79
- "tags" => route.swagger_meta[:tags] || [extract_tag(route.path)],
122
+ "operationId" => unique_operation_id(method, path, ctx[:seen_ids]),
123
+ "summary" => meta[:summary] || "#{method.upcase} #{route.path}",
124
+ "description" => meta[:description] || "",
125
+ "tags" => tags,
80
126
  "parameters" => build_parameters(route),
81
- "responses" => route.swagger_meta[:responses] || default_responses
127
+ "responses" => meta[:responses] || model_or_default_responses(ref, meta[:model_list])
82
128
  }
83
129
 
130
+ operation["deprecated"] = true if meta[:deprecated]
131
+
84
132
  if route.auth_handler
85
133
  operation["security"] = [{ "bearerAuth" => [] }]
86
134
  end
87
135
 
88
- if %w[post put patch].include?(method) && route.swagger_meta[:request_body]
89
- operation["requestBody"] = route.swagger_meta[:request_body]
90
- elsif %w[post put patch].include?(method)
91
- operation["requestBody"] = default_request_body
136
+ if %w[post put patch].include?(method)
137
+ operation["requestBody"] = build_request_body(method, meta, ref)
92
138
  end
93
139
 
94
140
  spec["paths"][path][method] = operation
95
141
  end
96
142
 
143
+ def build_request_body(_method, meta, ref)
144
+ return meta[:request_body] if meta[:request_body]
145
+
146
+ schema = ref ? { "$ref" => ref } : { "type" => "object" }
147
+ content = { "schema" => schema }
148
+ content["example"] = meta[:example] if meta[:example]
149
+ { "content" => { "application/json" => content } }
150
+ end
151
+
152
+ def model_or_default_responses(ref, model_list)
153
+ return default_responses unless ref
154
+
155
+ schema = model_list ? { "type" => "array", "items" => { "$ref" => ref } } : { "$ref" => ref }
156
+ {
157
+ "200" => {
158
+ "description" => "Successful response",
159
+ "content" => { "application/json" => { "schema" => schema } }
160
+ }
161
+ }
162
+ end
163
+
97
164
  def convert_path(path)
98
- # Convert {id:int} to {id}
99
- path.gsub(/\{(\w+)(?::\w+)?\}/, '{\1}')
165
+ # {id:int} -> {id}
166
+ p = path.gsub(/\{(\w+)(?::\w+)?\}/, '{\1}')
167
+ # splat *path -> {path}; bare /* -> /{wildcard} (a literal '*' segment
168
+ # or an orphaned splat param is invalid OpenAPI templating)
169
+ p = p.gsub(/\*(\w+)/, '{\1}')
170
+ p.gsub(%r{(?<=/)\*(?=/|$)}, "{wildcard}")
100
171
  end
101
172
 
102
173
  def extract_tag(path)
103
174
  parts = path.split("/").reject(&:empty?)
104
- parts.first || "default"
175
+ first = parts.first
176
+ return "default" if first.nil? || first.start_with?("{", "*")
177
+
178
+ first
179
+ end
180
+
181
+ def unique_operation_id(method, path, seen)
182
+ base = method + path.gsub(%r{[/{}*]}) { |c| c == "*" ? "wildcard" : "_" }
183
+ base = base.gsub(/_+/, "_").chomp("_")
184
+ oid = base
185
+ n = 2
186
+ while seen.include?(oid)
187
+ oid = "#{base}_#{n}"
188
+ n += 1
189
+ end
190
+ seen << oid
191
+ oid
105
192
  end
106
193
 
107
194
  def build_parameters(route)
108
195
  params = []
109
196
  route.param_names.each do |param|
197
+ name = param[:name].to_s
198
+ name = "wildcard" if name == "*"
110
199
  params << {
111
- "name" => param[:name].to_s,
200
+ "name" => name,
112
201
  "in" => "path",
113
202
  "required" => true,
114
203
  "schema" => param_schema(param[:type])
@@ -118,16 +207,71 @@ module Tina4
118
207
  end
119
208
 
120
209
  def param_schema(type)
121
- case type
210
+ case type.to_s
122
211
  when "int", "integer"
123
212
  { "type" => "integer" }
124
213
  when "float", "number"
125
214
  { "type" => "number" }
215
+ when "uuid"
216
+ { "type" => "string", "format" => "uuid" }
217
+ when "slug"
218
+ { "type" => "string", "pattern" => "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
219
+ when "alpha"
220
+ { "type" => "string", "pattern" => "^[A-Za-z]+$" }
221
+ when "alnum"
222
+ { "type" => "string", "pattern" => "^[A-Za-z0-9]+$" }
223
+ else
224
+ { "type" => "string" }
225
+ end
226
+ end
227
+
228
+ # Build a components.schemas object from an ORM model's field definitions.
229
+ def model_schema(model_class)
230
+ props = {}
231
+ required = []
232
+ defs = model_class.respond_to?(:field_definitions) ? model_class.field_definitions : {}
233
+ defs.each do |name, opts|
234
+ props[name.to_s] = field_schema(opts)
235
+ required << name.to_s if opts[:nullable] == false
236
+ end
237
+ schema = { "type" => "object", "properties" => props.empty? ? {} : props }
238
+ schema["required"] = required unless required.empty?
239
+ schema
240
+ end
241
+
242
+ def field_schema(opts)
243
+ schema = map_field_type(opts[:type])
244
+ schema["readOnly"] = true if opts[:primary_key] && opts[:auto_increment]
245
+ schema
246
+ end
247
+
248
+ def map_field_type(type)
249
+ case type.to_s
250
+ when "integer"
251
+ { "type" => "integer" }
252
+ when "float", "decimal", "numeric"
253
+ { "type" => "number" }
254
+ when "boolean"
255
+ { "type" => "boolean" }
256
+ when "datetime", "date", "timestamp"
257
+ { "type" => "string", "format" => "date-time" }
258
+ when "blob"
259
+ { "type" => "string", "format" => "byte" }
126
260
  else
127
261
  { "type" => "string" }
128
262
  end
129
263
  end
130
264
 
265
+ def resolve_model(name)
266
+ Object.const_get(name)
267
+ rescue NameError
268
+ begin
269
+ Tina4.const_get(name)
270
+ rescue NameError
271
+ nil
272
+ end
273
+ end
274
+
131
275
  def default_responses
132
276
  {
133
277
  "200" => { "description" => "Successful response" },
@@ -137,16 +281,6 @@ module Tina4
137
281
  "500" => { "description" => "Internal server error" }
138
282
  }
139
283
  end
140
-
141
- def default_request_body
142
- {
143
- "content" => {
144
- "application/json" => {
145
- "schema" => { "type" => "object" }
146
- }
147
- }
148
- }
149
- end
150
284
  end
151
285
  end
152
286
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.39"
4
+ VERSION = "3.13.40"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -50,6 +50,7 @@ require_relative "tina4/error_overlay"
50
50
  require_relative "tina4/test_client"
51
51
  require_relative "tina4/test"
52
52
  require_relative "tina4/docs"
53
+ require_relative "tina4/docstore"
53
54
  require_relative "tina4/mcp"
54
55
 
55
56
  module Tina4
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.39
4
+ version: 3.13.40
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-21 00:00:00.000000000 Z
11
+ date: 2026-06-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -278,6 +278,7 @@ files:
278
278
  - lib/tina4/dev_admin.rb
279
279
  - lib/tina4/dev_mailbox.rb
280
280
  - lib/tina4/docs.rb
281
+ - lib/tina4/docstore.rb
281
282
  - lib/tina4/drivers/firebird_driver.rb
282
283
  - lib/tina4/drivers/mongodb_driver.rb
283
284
  - lib/tina4/drivers/mssql_driver.rb