tina4ruby 3.2.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9e0a35ede3d5672d8d4b1c4197129db19443f60638e0596b1e94f367f9a40aa
4
- data.tar.gz: 37d5d5d98b0511ac523eeec07d8b2ada21d5a47553bab176ccbce9a01a600389
3
+ metadata.gz: b43c80adf7c00f71f0c14d64b4660f612b0124008b6a4821f431bae1d25c88d8
4
+ data.tar.gz: ca4230e2b17265849c05e5118c095f7359a7728036e299dc2813a56c53f85c47
5
5
  SHA512:
6
- metadata.gz: 9f9f9b1240111c24ba20e461439c55c043792206f5b2150811f2c8d97b1e0e7e937d644777d4c2a06c59888b994f01aa8d54ebcffdad5dffcbdca159f3868fab
7
- data.tar.gz: 581ac0b6d4b0493f0d787d53846d9c1811e9dab6515797ad693ecceec8e5b7401e92d08319b434c3729df0321b3399b8b73019e2b701d39664fa2f69c8baa20a
6
+ metadata.gz: 8200644fd078c8c4cb635610bef5392daddda3f86d1d7366973d16ffbd9457eb9e710db6cba8430384ff6b052b55dbbff0129c3ca31f878e067aab608e053ed0
7
+ data.tar.gz: 900b1b9f2fa791c1ebfcd5f0e492d73ade490c7c48126d3b2b1ff71f5d6a41ae8991552d69ae2ae3b4bdbff884a6aebfd3f37d01b6fa2bb163d2779dea53a19d
data/README.md CHANGED
@@ -9,6 +9,14 @@
9
9
  Laravel joy. Ruby speed. 10x less code. Zero third-party dependencies.
10
10
  </p>
11
11
 
12
+ <p align="center">
13
+ <a href="https://rubygems.org/gems/tina4"><img src="https://img.shields.io/gem/v/tina4?color=7b1fa2&label=RubyGems" alt="Gem"></a>
14
+ <img src="https://img.shields.io/badge/tests-1%2C578%20passing-brightgreen" alt="Tests">
15
+ <img src="https://img.shields.io/badge/features-38-blue" alt="Features">
16
+ <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
17
+ <a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
18
+ </p>
19
+
12
20
  <p align="center">
13
21
  <a href="https://tina4.com">Documentation</a> &bull;
14
22
  <a href="#getting-started">Getting Started</a> &bull;
@@ -17,14 +25,6 @@
17
25
  <a href="https://tina4.com">tina4.com</a>
18
26
  </p>
19
27
 
20
- <p align="center">
21
- <img src="https://img.shields.io/badge/tests-1577%20passing-brightgreen" alt="Tests">
22
- <img src="https://img.shields.io/badge/carbonah-A%2B%20rated-00cc44" alt="Carbonah A+">
23
- <img src="https://img.shields.io/badge/zero--dep-core-blue" alt="Zero Dependencies">
24
- <img src="https://img.shields.io/badge/ruby-3.1%2B-blue" alt="Ruby 3.1+">
25
- <img src="https://img.shields.io/badge/license-MIT-lightgrey" alt="MIT License">
26
- </p>
27
-
28
28
  ---
29
29
 
30
30
  ## Quick Start
@@ -87,16 +87,16 @@ Every feature is built from scratch -- no gem install, no node_modules, no third
87
87
  | **Templates** | Frond engine (Twig-compatible), inheritance, partials, 35+ filters, macros, fragment caching, sandboxing |
88
88
  | **ORM** | Active Record, typed fields with validation, soft delete, relationships (`has_one`/`has_many`/`belongs_to`), scopes, result caching, multi-database |
89
89
  | **Database** | SQLite, PostgreSQL, MySQL, MSSQL, Firebird -- unified adapter interface, query caching (TINA4_DB_CACHE=true for 4x speedup) |
90
- | **Auth** | Zero-dep JWT (RS256), sessions (file, Redis, MongoDB), password hashing, form tokens |
91
- | **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE |
92
- | **Background** | DB-backed queue with priority, delayed jobs, retry, batch processing, multi-queue |
90
+ | **Auth** | Zero-dep JWT (HS256/RS256), sessions (file/Redis/Valkey/MongoDB/database), password hashing, form tokens |
91
+ | **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE, WSDL/SOAP with auto WSDL |
92
+ | **Background** | Queue (SQLite/RabbitMQ/Kafka/MongoDB) with priority, delayed jobs, retry, batch processing |
93
93
  | **Real-time** | Native WebSocket (RFC 6455), per-path routing, connection manager |
94
94
  | **Frontend** | tina4-css (~24 KB), frond.js helper, SCSS compiler, live reload, CSS hot-reload |
95
95
  | **DX** | Dev admin dashboard, error overlay, request inspector, AI tool integration, Carbonah green benchmarks |
96
96
  | **Data** | Migrations with rollback, 50+ fake data generators, ORM and table seeders |
97
97
  | **Other** | REST client, localization (6 languages), cache (memory/Redis/file), event system, inline testing, messenger (.env driven), configurable error pages |
98
98
 
99
- **1,577 tests across 38 built-in features. Zero dependencies. All Carbonah benchmarks rated A+.**
99
+ **1,578 tests across 38 built-in features. Zero dependencies. All Carbonah benchmarks rated A+.**
100
100
 
101
101
  For full documentation visit **[tina4.com](https://tina4.com)**.
102
102
 
@@ -358,7 +358,7 @@ db = Tina4::Database.new("mysql://localhost:3306/mydb", username: "user", passwo
358
358
  db = Tina4::Database.new("mssql://localhost:1433/mydb", username: "sa", password: "pass")
359
359
  db = Tina4::Database.new("firebird://localhost:3050/path/to/db", username: "SYSDBA", password: "masterkey")
360
360
 
361
- result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit: 20, skip: 0)
361
+ result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit: 20, offset: 0)
362
362
  row = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
363
363
  db.insert("users", { name: "Alice", email: "alice@test.com" })
364
364
  db.commit
@@ -381,8 +381,8 @@ end
381
381
  ### JWT Authentication
382
382
 
383
383
  ```ruby
384
- token = Tina4::Auth.create_token({ user_id: 42 })
385
- result = Tina4::Auth.validate_token(token)
384
+ token = Tina4::Auth.get_token({ user_id: 42 })
385
+ result = Tina4::Auth.valid_token(token)
386
386
  payload = Tina4::Auth.get_payload(token)
387
387
  ```
388
388
 
@@ -400,11 +400,10 @@ Backends: file (default), Redis, MongoDB. Set via `TINA4_SESSION_HANDLER` in `.e
400
400
  ### Queues
401
401
 
402
402
  ```ruby
403
- producer = Tina4::Producer.new(Tina4::Queue.new(topic: "emails"))
404
- producer.produce({ to: "alice@example.com" })
403
+ queue = Tina4::Queue.new(topic: "emails")
404
+ queue.produce("emails", { to: "alice@example.com" })
405
405
 
406
- consumer = Tina4::Consumer.new(Tina4::Queue.new(topic: "emails"))
407
- consumer.each { |msg| send_email(msg.data) }
406
+ queue.consume("emails") { |msg| send_email(msg.payload) }
408
407
  ```
409
408
 
410
409
  ### GraphQL
@@ -607,7 +606,7 @@ DATABASE_USERNAME=
607
606
  DATABASE_PASSWORD=
608
607
  TINA4_DEBUG=true # Enable dev toolbar, error overlay
609
608
  TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
610
- TINA4_LANGUAGE=en # en, fr, af, zh, ja, es
609
+ TINA4_LOCALE=en # en, fr, af, zh, ja, es
611
610
  TINA4_SESSION_HANDLER=SessionFileHandler
612
611
  SWAGGER_TITLE=My API
613
612
  ```
data/lib/tina4/auth.rb CHANGED
@@ -12,27 +12,130 @@ module Tina4
12
12
  def setup(root_dir = Dir.pwd)
13
13
  @keys_dir = File.join(root_dir, KEYS_DIR)
14
14
  FileUtils.mkdir_p(@keys_dir)
15
- ensure_keys
15
+ ensure_keys unless use_hmac?
16
16
  end
17
17
 
18
- def create_token(payload, expires_in: 3600)
19
- ensure_keys
18
+ # ── HS256 helpers (stdlib only, no gem) ──────────────────────
19
+
20
+ # Returns true when SECRET env var is set and no RSA keys exist in .keys/
21
+ def use_hmac?
22
+ secret = ENV["SECRET"]
23
+ return false if secret.nil? || secret.empty?
24
+
25
+ # If RSA keys already exist on disk, prefer RS256 for backward compat
26
+ @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
27
+ !(File.exist?(File.join(@keys_dir, "private.pem")) &&
28
+ File.exist?(File.join(@keys_dir, "public.pem")))
29
+ end
30
+
31
+ def hmac_secret
32
+ ENV["SECRET"]
33
+ end
34
+
35
+ # Base64url-encode without padding (JWT spec)
36
+ def base64url_encode(data)
37
+ Base64.urlsafe_encode64(data, padding: false)
38
+ end
39
+
40
+ # Base64url-decode (handles missing padding)
41
+ def base64url_decode(str)
42
+ # Add back padding
43
+ remainder = str.length % 4
44
+ str += "=" * ((4 - remainder) % 4) if remainder != 0
45
+ Base64.urlsafe_decode64(str)
46
+ end
47
+
48
+ # Build a JWT using HS256 with Ruby's OpenSSL::HMAC (no gem needed)
49
+ def hmac_encode(claims, secret)
50
+ header = { "alg" => "HS256", "typ" => "JWT" }
51
+ segments = [
52
+ base64url_encode(JSON.generate(header)),
53
+ base64url_encode(JSON.generate(claims))
54
+ ]
55
+ signing_input = segments.join(".")
56
+ signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
57
+ segments << base64url_encode(signature)
58
+ segments.join(".")
59
+ end
60
+
61
+ # Decode and verify a JWT signed with HS256. Returns the payload hash or nil.
62
+ def hmac_decode(token, secret)
63
+ parts = token.split(".")
64
+ return nil unless parts.length == 3
65
+
66
+ header_json = base64url_decode(parts[0])
67
+ header = JSON.parse(header_json)
68
+ return nil unless header["alg"] == "HS256"
69
+
70
+ # Verify signature
71
+ signing_input = "#{parts[0]}.#{parts[1]}"
72
+ expected_sig = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
73
+ actual_sig = base64url_decode(parts[2])
74
+
75
+ # Constant-time comparison to prevent timing attacks
76
+ return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
77
+
78
+ payload = JSON.parse(base64url_decode(parts[1]))
79
+
80
+ # Check expiry
81
+ now = Time.now.to_i
82
+ return nil if payload["exp"] && now >= payload["exp"]
83
+ return nil if payload["nbf"] && now < payload["nbf"]
84
+
85
+ payload
86
+ rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
87
+ nil
88
+ end
89
+
90
+ # ── Token API (auto-selects HS256 or RS256) ─────────────────
91
+
92
+ def get_token(payload, expires_in: 3600)
20
93
  now = Time.now.to_i
21
94
  claims = payload.merge(
22
95
  "iat" => now,
23
96
  "exp" => now + expires_in,
24
97
  "nbf" => now
25
98
  )
26
- require "jwt"
27
- JWT.encode(claims, private_key, "RS256")
99
+
100
+ if use_hmac?
101
+ hmac_encode(claims, hmac_secret)
102
+ else
103
+ ensure_keys
104
+ require "jwt"
105
+ JWT.encode(claims, private_key, "RS256")
106
+ end
28
107
  end
29
108
 
30
109
 
31
- def validate_token(token)
32
- ensure_keys
33
- require "jwt"
34
- decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
35
- { valid: true, payload: decoded[0] }
110
+ def valid_token(token)
111
+ if use_hmac?
112
+ hmac_decode(token, hmac_secret)
113
+ else
114
+ ensure_keys
115
+ require "jwt"
116
+ decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
117
+ decoded[0]
118
+ end
119
+ rescue JWT::ExpiredSignature
120
+ nil
121
+ rescue JWT::DecodeError
122
+ nil
123
+ end
124
+
125
+ def valid_token_detail(token)
126
+ if use_hmac?
127
+ payload = hmac_decode(token, hmac_secret)
128
+ if payload
129
+ { valid: true, payload: payload }
130
+ else
131
+ { valid: false, error: "Invalid or expired token" }
132
+ end
133
+ else
134
+ ensure_keys
135
+ require "jwt"
136
+ decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
137
+ { valid: true, payload: decoded[0] }
138
+ end
36
139
  rescue JWT::ExpiredSignature
37
140
  { valid: false, error: "Token expired" }
38
141
  rescue JWT::DecodeError => e
@@ -53,34 +156,36 @@ module Tina4
53
156
 
54
157
 
55
158
  def get_payload(token)
56
- require "jwt"
57
- decoded = JWT.decode(token, nil, false)
58
- decoded[0]
59
- rescue JWT::DecodeError
159
+ parts = token.split(".")
160
+ return nil unless parts.length == 3
161
+
162
+ payload_json = base64url_decode(parts[1])
163
+ JSON.parse(payload_json)
164
+ rescue ArgumentError, JSON::ParserError
60
165
  nil
61
166
  end
62
167
 
63
168
  def refresh_token(token, expires_in: 3600)
64
- result = validate_token(token)
65
- return nil unless result[:valid]
169
+ payload = valid_token(token)
170
+ return nil unless payload
66
171
 
67
- payload = result[:payload].reject { |k, _| %w[iat exp nbf].include?(k) }
68
- create_token(payload, expires_in: expires_in)
172
+ payload = payload.reject { |k, _| %w[iat exp nbf].include?(k) }
173
+ get_token(payload, expires_in: expires_in)
69
174
  end
70
175
 
71
176
  def authenticate_request(headers)
72
177
  auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
73
- return { valid: false, error: "No authorization header" } unless auth_header =~ /\ABearer\s+(.+)\z/i
178
+ return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
74
179
 
75
180
  token = Regexp.last_match(1)
76
181
 
77
182
  # API_KEY bypass — matches tina4_python behavior
78
183
  api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
79
184
  if api_key && !api_key.empty? && token == api_key
80
- return { valid: true, payload: { "api_key" => true } }
185
+ return { "api_key" => true }
81
186
  end
82
187
 
83
- validate_token(token)
188
+ valid_token(token)
84
189
  end
85
190
 
86
191
  def validate_api_key(provided, expected: nil)
@@ -107,15 +212,15 @@ module Tina4
107
212
  token = Regexp.last_match(1)
108
213
 
109
214
  # API_KEY bypass — matches tina4_python behavior
110
- api_key = ENV["API_KEY"]
215
+ api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
111
216
  if api_key && !api_key.empty? && token == api_key
112
217
  env["tina4.auth"] = { "api_key" => true }
113
218
  return true
114
219
  end
115
220
 
116
- result = validate_token(token)
117
- if result[:valid]
118
- env["tina4.auth"] = result[:payload]
221
+ payload = valid_token(token)
222
+ if payload
223
+ env["tina4.auth"] = payload
119
224
  true
120
225
  else
121
226
  false
@@ -129,6 +234,10 @@ module Tina4
129
234
  @default_secure_auth ||= bearer_auth
130
235
  end
131
236
 
237
+ # Legacy aliases
238
+ alias_method :create_token, :get_token
239
+ alias_method :validate_token, :valid_token_detail
240
+
132
241
  def private_key
133
242
  @private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path))
134
243
  end
@@ -140,6 +249,8 @@ module Tina4
140
249
  private
141
250
 
142
251
  def ensure_keys
252
+ return if use_hmac?
253
+
143
254
  @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
144
255
  FileUtils.mkdir_p(@keys_dir)
145
256
  unless File.exist?(private_key_path) && File.exist?(public_key_path)
@@ -169,8 +280,7 @@ module Tina4
169
280
  return true if auth_header.empty?
170
281
 
171
282
  if auth_header =~ /\ABearer\s+(.+)\z/i
172
- result = validate_token(Regexp.last_match(1))
173
- result[:valid]
283
+ !valid_token(Regexp.last_match(1)).nil?
174
284
  else
175
285
  false
176
286
  end
@@ -21,10 +21,36 @@ module Tina4
21
21
  end
22
22
  end
23
23
 
24
+ # Build a sample request body from ORM field definitions.
25
+ def build_example(model_class)
26
+ example = {}
27
+ return example unless model_class.respond_to?(:field_definitions)
28
+
29
+ model_class.field_definitions.each do |name, opts|
30
+ next if opts[:primary_key] && opts[:auto_increment]
31
+
32
+ case opts[:type]
33
+ when :integer
34
+ example[name.to_s] = 0
35
+ when :numeric, :float, :decimal
36
+ example[name.to_s] = 0.0
37
+ when :boolean
38
+ example[name.to_s] = true
39
+ when :datetime
40
+ example[name.to_s] = "2024-01-01T00:00:00"
41
+ else
42
+ example[name.to_s] = "string"
43
+ end
44
+ end
45
+ example
46
+ end
47
+
24
48
  # Generate REST endpoints for a single model class
25
49
  def generate_routes_for(model_class, prefix: "/api")
26
50
  table = model_class.table_name
27
51
  pk = model_class.primary_key_field || :id
52
+ pretty_name = table.to_s.split("_").map(&:capitalize).join(" ")
53
+ example_body = build_example(model_class)
28
54
 
29
55
  # GET /api/{table} -- list all with pagination, filtering, sorting
30
56
  Tina4::Router.add_route("GET", "#{prefix}/#{table}", proc { |req, res|
@@ -63,7 +89,7 @@ module Tina4
63
89
  rescue => e
64
90
  res.json({ error: e.message }, status: 500)
65
91
  end
66
- })
92
+ }, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s] })
67
93
 
68
94
  # GET /api/{table}/{id} -- get single record
69
95
  Tina4::Router.add_route("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
@@ -78,7 +104,7 @@ module Tina4
78
104
  rescue => e
79
105
  res.json({ error: e.message }, status: 500)
80
106
  end
81
- })
107
+ }, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s] })
82
108
 
83
109
  # POST /api/{table} -- create record
84
110
  Tina4::Router.add_route("POST", "#{prefix}/#{table}", proc { |req, res|
@@ -93,6 +119,19 @@ module Tina4
93
119
  rescue => e
94
120
  res.json({ error: e.message }, status: 500)
95
121
  end
122
+ }, swagger_meta: {
123
+ summary: "Create #{pretty_name}",
124
+ tags: [table.to_s],
125
+ request_body: {
126
+ "description" => "#{pretty_name} data",
127
+ "required" => true,
128
+ "content" => {
129
+ "application/json" => {
130
+ "schema" => { "type" => "object" },
131
+ "example" => example_body
132
+ }
133
+ }
134
+ }
96
135
  })
97
136
 
98
137
  # PUT /api/{table}/{id} -- update record
@@ -118,6 +157,19 @@ module Tina4
118
157
  rescue => e
119
158
  res.json({ error: e.message }, status: 500)
120
159
  end
160
+ }, swagger_meta: {
161
+ summary: "Update #{pretty_name}",
162
+ tags: [table.to_s],
163
+ request_body: {
164
+ "description" => "#{pretty_name} data",
165
+ "required" => true,
166
+ "content" => {
167
+ "application/json" => {
168
+ "schema" => { "type" => "object" },
169
+ "example" => example_body
170
+ }
171
+ }
172
+ }
121
173
  })
122
174
 
123
175
  # DELETE /api/{table}/{id} -- delete record
@@ -137,7 +189,7 @@ module Tina4
137
189
  rescue => e
138
190
  res.json({ error: e.message }, status: 500)
139
191
  end
140
- })
192
+ }, swagger_meta: { summary: "Delete #{pretty_name}", tags: [table.to_s] })
141
193
  end
142
194
 
143
195
  def clear!
data/lib/tina4/cli.rb CHANGED
@@ -5,7 +5,7 @@ require "fileutils"
5
5
 
6
6
  module Tina4
7
7
  class CLI
8
- COMMANDS = %w[init start migrate seed seed:create test version routes console generate ai help].freeze
8
+ COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
9
9
 
10
10
  def self.start(argv)
11
11
  new.run(argv)
@@ -17,6 +17,8 @@ module Tina4
17
17
  when "init" then cmd_init(argv)
18
18
  when "start" then cmd_start(argv)
19
19
  when "migrate" then cmd_migrate(argv)
20
+ when "migrate:status" then cmd_migrate_status(argv)
21
+ when "migrate:rollback" then cmd_migrate_rollback(argv)
20
22
  when "seed" then cmd_seed(argv)
21
23
  when "seed:create" then cmd_seed_create(argv)
22
24
  when "test" then cmd_test(argv)
@@ -180,6 +182,75 @@ module Tina4
180
182
  end
181
183
  end
182
184
 
185
+ # ── migrate:status ─────────────────────────────────────────────────────
186
+
187
+ def cmd_migrate_status(_argv)
188
+ require_relative "../tina4"
189
+ Tina4.initialize!(Dir.pwd)
190
+
191
+ db = Tina4.database
192
+ unless db
193
+ puts "No database configured. Set DATABASE_URL in your .env file."
194
+ return
195
+ end
196
+
197
+ migration = Tina4::Migration.new(db)
198
+ info = migration.status
199
+
200
+ puts "\nMigration Status"
201
+ puts "-" * 60
202
+
203
+ if info[:completed].any?
204
+ puts "\nCompleted:"
205
+ info[:completed].each { |name| puts " [OK] #{name}" }
206
+ end
207
+
208
+ if info[:pending].any?
209
+ puts "\nPending:"
210
+ info[:pending].each { |name| puts " [ ] #{name}" }
211
+ end
212
+
213
+ if info[:completed].empty? && info[:pending].empty?
214
+ puts " No migrations found."
215
+ end
216
+
217
+ puts "-" * 60
218
+ puts " Completed: #{info[:completed].length} Pending: #{info[:pending].length}\n"
219
+ end
220
+
221
+ # ── migrate:rollback ───────────────────────────────────────────────────
222
+
223
+ def cmd_migrate_rollback(argv)
224
+ options = { steps: 1 }
225
+ parser = OptionParser.new do |opts|
226
+ opts.banner = "Usage: tina4ruby migrate:rollback [options]"
227
+ opts.on("-n", "--steps N", Integer, "Number of batches to rollback (default: 1)") { |v| options[:steps] = v }
228
+ end
229
+ parser.parse!(argv)
230
+
231
+ require_relative "../tina4"
232
+ Tina4.initialize!(Dir.pwd)
233
+
234
+ db = Tina4.database
235
+ unless db
236
+ puts "No database configured. Set DATABASE_URL in your .env file."
237
+ return
238
+ end
239
+
240
+ migration = Tina4::Migration.new(db)
241
+ results = migration.rollback(options[:steps])
242
+
243
+ if results.empty?
244
+ puts "Nothing to rollback."
245
+ else
246
+ results.each do |r|
247
+ status_icon = r[:status] == "rolled_back" ? "OK" : "FAIL"
248
+ puts " [#{status_icon}] #{r[:name]}"
249
+ end
250
+ puts "Rolled back #{results.length} migration(s)."
251
+ end
252
+ end
253
+
183
254
  # ── seed ──────────────────────────────────────────────────────────────
184
255
 
185
256
  def cmd_seed(argv)
@@ -446,6 +517,8 @@ module Tina4
446
517
  init [NAME] Initialize a new Tina4 project
447
518
  start Start the Tina4 web server
448
519
  migrate Run database migrations
520
+ migrate:status Show migration status (completed and pending)
521
+ migrate:rollback Rollback the last batch of migrations
449
522
  seed Run all seed files in seeds/
450
523
  seed:create NAME Create a new seed file
451
524
  test Run inline tests
@@ -482,7 +555,7 @@ module Tina4
482
555
  # ── shared helpers ────────────────────────────────────────────────────
483
556
 
484
557
  def load_routes(root_dir)
485
- route_dirs = %w[routes src/routes src/api api]
558
+ route_dirs = %w[src/routes routes src/api api src/orm orm]
486
559
  route_dirs.each do |dir|
487
560
  route_dir = File.join(root_dir, dir)
488
561
  next unless Dir.exist?(route_dir)
data/lib/tina4/cors.rb CHANGED
@@ -52,7 +52,7 @@ module Tina4
52
52
  {
53
53
  origins: ENV["TINA4_CORS_ORIGINS"] || "*",
54
54
  methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
55
- headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type, Authorization, Accept",
55
+ headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
56
56
  max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
57
57
  credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
58
58
  }.freeze