better_auth-hanami 0.8.0 → 0.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: 38b660cd7b5342ffeb1604a0b1c31ec6b0258016e496f6910764d0c166242edb
4
- data.tar.gz: b8857da84de1c4d1ca206892aeaf438fd633ee729290074e2e68872930964428
3
+ metadata.gz: b23720fd336f4026363d337c886f73c6ab60369d3e3f8679d25b989712caf74d
4
+ data.tar.gz: 809a0912aaafdce964e6622c05f7fdc77892165524901f7e56970a7e34abac99
5
5
  SHA512:
6
- metadata.gz: 40d10b9a1922dc8b80c90362c124f4513b98e6cb0cd1895eae17cafb07fa30d5ec6473f19b3ce828ecf3db22181472c20edcbf6d98d888889f2c389c789b3cec
7
- data.tar.gz: 9c44539d3fbaee60870d67bb2e9fa79ef75b7d3d9cc1cc48cc1979fc256347fe282b29cb24eed9689d1c7850bd728a0b2208fbb7d3d7752d72f21626b9cc8a65
6
+ metadata.gz: ddad200e1d991c7b81a746828854b65359b01545f0e161a342ce8a1a7cfdf8ae1f82fd7ec1703d58ce728e10ec1b71defbcb2049508627eb0241988a1a256945
7
+ data.tar.gz: '038a9aaa4497e56a9fcf64ca88d650b3f750a75256c0d3a08b505dfbe58894d7a2d17d54ecd1cbce8a709c7b3d5187daee3b8912b41c59d7b3c1b6d09c790a04'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-05-21
11
+
12
+ ### Fixed
13
+
14
+ - Preserved migration foreign keys and improved generated migration output.
15
+ - Hardened Hanami routing, helpers, rate limits, rake tasks, and Sequel adapter behavior.
16
+
10
17
  ## [0.7.0] - 2026-05-05
11
18
 
12
19
  ### Fixed
data/README.md CHANGED
@@ -40,11 +40,15 @@ bin/hanami db migrate
40
40
  ```
41
41
 
42
42
  When you add plugins that introduce schema tables or fields, regenerate both
43
- the migration and the app query objects before migrating a new app:
43
+ the migration and the app query objects. If the base migration already exists
44
+ and Hanami can connect to the current Sequel database, the migration generator
45
+ creates a new incremental update migration for missing plugin tables, additional
46
+ fields, and indexes:
44
47
 
45
48
  ```bash
46
49
  bundle exec rake better_auth:generate:migration
47
50
  bundle exec rake better_auth:generate:relations
51
+ bundle exec rake better_auth:doctor
48
52
  ```
49
53
 
50
54
  ## Configuration
@@ -58,14 +62,17 @@ Hanami.app.register_provider(:better_auth) do
58
62
  end
59
63
 
60
64
  start do
65
+ better_auth_url = target["settings"].better_auth_url.to_s
66
+ raise "better_auth_url must be configured" if better_auth_url.empty?
67
+
61
68
  BetterAuth::Hanami.configure do |config|
62
69
  config.secret = target["settings"].better_auth_secret
63
- config.base_url = target["settings"].better_auth_url
70
+ config.base_url = better_auth_url
64
71
  config.base_path = "/api/auth"
65
72
  config.database = ->(options) {
66
73
  BetterAuth::Hanami::SequelAdapter.from_container(target, options)
67
74
  }
68
- config.trusted_origins = [target["settings"].better_auth_url].compact
75
+ config.trusted_origins = [better_auth_url]
69
76
  config.email_and_password = {enabled: true}
70
77
  config.plugins = []
71
78
  end
@@ -84,8 +91,10 @@ CORS middleware in your Hanami app as well so preflight requests and
84
91
  policy. For the shared Rack/CORS/CSRF boundary, see
85
92
  [`host-app-responsibilities.md`](../../.docs/features/host-app-responsibilities.md).
86
93
 
87
- Do not rely on a Hanami-only empty `trusted_origins` list as a strict
88
- deny-all-origin policy; set real deployment URLs in app settings. Keep
94
+ Do not rely on a Hanami-only empty `trusted_origins` list or inferred request
95
+ hosts as a strict deny-all-origin policy; set a canonical deployment URL in app
96
+ settings. The generated provider raises when `better_auth_url` is blank so auth
97
+ URLs and origin checks are not derived from an untrusted `Host` header. Keep
89
98
  `BetterAuth::Hanami::MountedApp` behavior aligned with Hanami's router instead
90
99
  of copying Rails mount internals without integration tests. Be cautious with
91
100
  relation or inflector overrides generated for an app, because overwriting
@@ -174,6 +183,10 @@ BetterAuth::Hanami.configure do |config|
174
183
  end
175
184
  ```
176
185
 
186
+ When no Hanami `db.gateway` is available, the adapter still falls back to
187
+ memory storage in development and tests with a warning. In production it raises
188
+ instead, unless you intentionally set `config.allow_memory_fallback = true`.
189
+
177
190
  ## Limitations
178
191
 
179
192
  - Supports Hanami 2.3+ only. Better Auth core depends on Rack 3, and Hanami 2.3 is the first Hanami line that allows Rack 3.
@@ -20,6 +20,7 @@ module BetterAuth
20
20
  def require_authentication(request, response)
21
21
  return true if authenticated?(request)
22
22
 
23
+ apply_better_auth_session_headers(request, response)
23
24
  response.status = 401 if response.respond_to?(:status=)
24
25
  false
25
26
  end
@@ -36,18 +37,28 @@ module BetterAuth
36
37
  def resolve_better_auth_session(request)
37
38
  auth = BetterAuth::Hanami.auth
38
39
  auth.context.prepare_for_request!(request) if auth.context.respond_to?(:prepare_for_request!)
39
-
40
- context = BetterAuth::Endpoint::Context.new(
41
- path: request_path(request),
42
- method: request_method(request),
43
- query: request_params(request),
40
+ endpoint = auth.api.endpoints.fetch(:get_session)
41
+ endpoint_context = BetterAuth::Endpoint::Context.new(
42
+ path: endpoint.path,
43
+ method: "GET",
44
+ query: {"disableRefresh" => "true"},
44
45
  body: {},
45
46
  params: {},
46
- headers: {"cookie" => request_cookie(request)},
47
+ headers: request_headers(request),
47
48
  context: auth.context,
48
49
  request: request
49
50
  )
50
- BetterAuth::Session.find_current(context, disable_refresh: true)
51
+ result = auth.api.execute(endpoint, endpoint_context)
52
+ request_env(request)["better_auth.session_headers"] = result.headers || {}
53
+ session = result.response
54
+ return nil unless session
55
+ return nil if session.is_a?(BetterAuth::APIError)
56
+
57
+ {session: session["session"] || session[:session], user: session["user"] || session[:user]}
58
+ rescue BetterAuth::APIError
59
+ nil
60
+ ensure
61
+ auth.context.clear_runtime! if defined?(auth) && auth.context.respond_to?(:clear_runtime!)
51
62
  end
52
63
 
53
64
  def request_env(request)
@@ -72,6 +83,52 @@ module BetterAuth
72
83
  headers = request.respond_to?(:headers) ? request.headers : {}
73
84
  headers["cookie"] || headers["Cookie"]
74
85
  end
86
+
87
+ def request_authorization(request)
88
+ return request.get_header("HTTP_AUTHORIZATION") if request.respond_to?(:get_header)
89
+
90
+ headers = request.respond_to?(:headers) ? request.headers : {}
91
+ headers["authorization"] || headers["Authorization"]
92
+ end
93
+
94
+ def request_headers(request)
95
+ headers = headers_from_env(request_env(request))
96
+ cookie = request_cookie(request)
97
+ authorization = request_authorization(request)
98
+ headers["cookie"] = cookie if cookie
99
+ headers["authorization"] = authorization if authorization
100
+ if request.respond_to?(:headers)
101
+ request.headers.each { |key, value| headers[key.to_s] ||= value }
102
+ end
103
+ headers
104
+ end
105
+
106
+ def headers_from_env(env)
107
+ env.each_with_object({}) do |(key, value), headers|
108
+ case key
109
+ when "CONTENT_TYPE"
110
+ headers["content-type"] = value if value
111
+ when "CONTENT_LENGTH"
112
+ headers["content-length"] = value if value
113
+ else
114
+ next unless key.start_with?("HTTP_")
115
+
116
+ headers[key.delete_prefix("HTTP_").downcase.tr("_", "-")] = value
117
+ end
118
+ end
119
+ end
120
+
121
+ def apply_better_auth_session_headers(request, response)
122
+ headers = request_env(request)["better_auth.session_headers"]
123
+ return unless headers && headers["set-cookie"]
124
+
125
+ if response.respond_to?(:headers) && response.headers.respond_to?(:[]=)
126
+ existing = response.headers["set-cookie"]
127
+ response.headers["set-cookie"] = [existing, headers["set-cookie"]].compact.join("\n")
128
+ elsif response.respond_to?(:[]=)
129
+ response["set-cookie"] = headers["set-cookie"]
130
+ end
131
+ end
75
132
  end
76
133
  end
77
134
  end
@@ -30,13 +30,14 @@ module BetterAuth
30
30
  logger
31
31
  ].freeze
32
32
 
33
- attr_accessor(*AUTH_OPTION_NAMES)
33
+ attr_accessor(*AUTH_OPTION_NAMES, :allow_memory_fallback)
34
34
 
35
35
  def initialize
36
36
  @base_path = BetterAuth::Configuration::DEFAULT_BASE_PATH
37
37
  @plugins = []
38
38
  @trusted_origins = []
39
- @database = ->(options) { SequelAdapter.from_hanami(options) }
39
+ @allow_memory_fallback = false
40
+ @database = ->(options) { SequelAdapter.from_hanami(options, allow_memory_fallback: allow_memory_fallback) }
40
41
  end
41
42
 
42
43
  def to_auth_options
@@ -65,11 +65,16 @@ module BetterAuth
65
65
  end
66
66
 
67
67
  content = File.read(path)
68
- return if content.include?("setting :better_auth_secret")
68
+ required_url_setting = "setting :better_auth_url, constructor: Types::String.constrained(min_size: 1)"
69
+ content = content.gsub("setting :better_auth_url, constructor: Types::String.optional", required_url_setting)
70
+ if content.include?("setting :better_auth_secret")
71
+ File.write(path, content)
72
+ return
73
+ end
69
74
 
70
75
  insertion = [
71
76
  " setting :better_auth_secret, constructor: Types::String.constrained(min_size: 32)",
72
- " setting :better_auth_url, constructor: Types::String.optional"
77
+ " #{required_url_setting}"
73
78
  ].join("\n")
74
79
  content = content.sub(/(class[ \t]+Settings[ \t]*<[ \t]*Hanami::Settings[ \t]*\n)/, "\\1#{insertion}\n")
75
80
  File.write(path, content)
@@ -96,14 +101,17 @@ module BetterAuth
96
101
  end
97
102
 
98
103
  start do
104
+ better_auth_url = target["settings"].better_auth_url.to_s
105
+ raise "better_auth_url must be configured" if better_auth_url.empty?
106
+
99
107
  BetterAuth::Hanami.configure do |config|
100
108
  config.secret = target["settings"].better_auth_secret
101
- config.base_url = target["settings"].better_auth_url
109
+ config.base_url = better_auth_url
102
110
  config.base_path = "/api/auth"
103
111
  config.database = ->(options) {
104
112
  BetterAuth::Hanami::SequelAdapter.from_container(target, options)
105
113
  }
106
- config.trusted_origins = [target["settings"].better_auth_url].compact
114
+ config.trusted_origins = [better_auth_url]
107
115
  config.email_and_password = {enabled: true}
108
116
  config.plugins = []
109
117
  end
@@ -15,7 +15,7 @@ module BetterAuth
15
15
 
16
16
  def run(force: nil)
17
17
  force = @force if force.nil?
18
- return existing_migration_path if existing_migration_path && !force
18
+ return incremental_migration_path_if_needed if existing_migration_path && !force
19
19
 
20
20
  path = existing_migration_path || migration_path
21
21
  FileUtils.mkdir_p(File.dirname(path))
@@ -35,8 +35,20 @@ module BetterAuth
35
35
  @migration_path ||= File.join(destination_root, "config/db/migrate", "#{timestamp}_create_better_auth_tables.rb")
36
36
  end
37
37
 
38
+ def incremental_migration_path_if_needed
39
+ plan = BetterAuth::Hanami::Migration.plan_pending(generator_config)
40
+ return existing_migration_path if plan.empty?
41
+
42
+ path = File.join(destination_root, "config/db/migrate", "#{timestamp}_update_better_auth_tables.rb")
43
+ FileUtils.mkdir_p(File.dirname(path))
44
+ File.write(path, BetterAuth::Hanami::Migration.render_pending(plan))
45
+ path
46
+ rescue BetterAuth::SQLMigration::UnsupportedAdapterError
47
+ existing_migration_path
48
+ end
49
+
38
50
  def timestamp
39
- Time.now.utc.strftime("%Y%m%d%H%M%S")
51
+ @timestamp ||= Time.now.utc.strftime("%Y%m%d%H%M%S")
40
52
  end
41
53
 
42
54
  def generator_config
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "better_auth/sql_migration"
4
+
3
5
  module BetterAuth
4
6
  module Hanami
5
7
  module Migration
@@ -21,6 +23,88 @@ module BetterAuth
21
23
  lines.join("\n")
22
24
  end
23
25
 
26
+ def render_pending(plan)
27
+ created_tables = plan.to_create.map(&:table_name).to_set
28
+ lines = [
29
+ "# frozen_string_literal: true",
30
+ "",
31
+ "require \"date\"",
32
+ "require \"rom-sql\"",
33
+ "",
34
+ "ROM::SQL.migration do",
35
+ " change do"
36
+ ]
37
+ plan.to_create.each { |change| lines.concat(create_table_lines(change.table, plan.tables)) }
38
+ plan.to_add.each { |change| lines.concat(alter_table_lines(change, plan.tables)) }
39
+ plan.to_index.reject { |change| created_tables.include?(change.table_name) }.each do |change|
40
+ lines.concat(alter_table_index_lines(change))
41
+ end
42
+ lines.concat([" end", "end", ""])
43
+ lines.join("\n")
44
+ end
45
+
46
+ def plan_pending(options)
47
+ config = BetterAuth::SQLMigration.configuration_for(options)
48
+ if config.database == :memory
49
+ raise BetterAuth::SQLMigration::UnsupportedAdapterError, "Better Auth Hanami incremental migrations require a Sequel connection"
50
+ end
51
+ if default_hanami_database?(config.database) && !(defined?(::Hanami) && ::Hanami.respond_to?(:app))
52
+ raise BetterAuth::SQLMigration::UnsupportedAdapterError, "Better Auth Hanami incremental migrations require a Sequel connection"
53
+ end
54
+ auth = BetterAuth.auth(config.to_h)
55
+ adapter = auth.context.adapter
56
+ connection = adapter.connection if adapter.respond_to?(:connection)
57
+ raise BetterAuth::SQLMigration::UnsupportedAdapterError, "Better Auth Hanami incremental migrations require a Sequel connection" unless connection
58
+
59
+ BetterAuth::SQLMigration.plan_from_existing(
60
+ config,
61
+ existing: current_schema(connection),
62
+ dialect: sequel_dialect(connection)
63
+ )
64
+ end
65
+
66
+ def sequel_dialect(connection)
67
+ type = connection.respond_to?(:database_type) ? connection.database_type.to_s : ""
68
+ case type
69
+ when /postgres/
70
+ :postgres
71
+ when /mysql/
72
+ :mysql
73
+ when /sqlite/
74
+ :sqlite
75
+ when /mssql|sqlserver|sql_server/
76
+ :mssql
77
+ else
78
+ :postgres
79
+ end
80
+ end
81
+
82
+ def current_schema(connection)
83
+ connection.tables.each_with_object({}) do |table_name, schema|
84
+ columns = connection.schema(table_name).each_with_object({}) do |entry, result|
85
+ column, metadata = entry
86
+ result[column.to_s] = (metadata[:db_type] || metadata[:type]).to_s
87
+ end
88
+ indexes = {names: Set.new, columns: Set.new, unique_columns: Set.new}
89
+ connection.indexes(table_name).each do |name, metadata|
90
+ indexes[:names] << name.to_s
91
+ Array(metadata[:columns]).each do |column|
92
+ column = column.to_s
93
+ indexes[:columns] << column
94
+ indexes[:unique_columns] << column if metadata[:unique]
95
+ end
96
+ end
97
+ schema[table_name.to_s] = {name: table_name.to_s, columns: columns, indexes: indexes}
98
+ end
99
+ end
100
+
101
+ def default_hanami_database?(database)
102
+ return false unless database.respond_to?(:source_location)
103
+
104
+ path, = database.source_location
105
+ path.to_s.end_with?("better_auth/hanami/configuration.rb")
106
+ end
107
+
24
108
  def create_table_lines(table, options)
25
109
  table_name = table.fetch(:model_name)
26
110
  lines = ["", " create_table :#{table_name} do"]
@@ -37,16 +121,9 @@ module BetterAuth
37
121
  end
38
122
 
39
123
  def column_line(logical_field, attributes, options)
40
- column = attributes[:field_name] || physical_name(logical_field)
41
- reference = attributes[:references]
42
- if reference
43
- target = foreign_key_target(reference.fetch(:model), options)
44
- parts = ["foreign_key :#{column}, :#{target}", "type: #{hanami_type(attributes)}"]
45
- parts << "null: false" if attributes[:required]
46
- parts << "on_delete: :#{reference[:on_delete]}" if reference[:on_delete]
47
- return " #{parts.join(", ")}"
48
- end
124
+ return foreign_key_line("foreign_key", logical_field, attributes, options) if attributes[:references]
49
125
 
126
+ column = attributes[:field_name] || physical_name(logical_field)
50
127
  parts = ["column :#{column}", hanami_type(attributes)]
51
128
  parts << "null: false" if attributes[:required]
52
129
  default = default_value(attributes)
@@ -62,6 +139,36 @@ module BetterAuth
62
139
  " index :#{column}#{unique}"
63
140
  end
64
141
 
142
+ def alter_table_lines(change, tables)
143
+ lines = ["", " alter_table :#{change.table_name} do"]
144
+ change.fields.each do |logical_field, attributes|
145
+ lines << add_column_line(logical_field, attributes, tables)
146
+ end
147
+ lines << " end"
148
+ lines
149
+ end
150
+
151
+ def add_column_line(logical_field, attributes, tables)
152
+ return foreign_key_line("add_foreign_key", logical_field, attributes, tables) if attributes[:references]
153
+
154
+ column = attributes[:field_name] || physical_name(logical_field)
155
+ parts = ["add_column :#{column}", hanami_type(attributes)]
156
+ parts << "null: false" if attributes[:required]
157
+ default = default_value(attributes)
158
+ parts << "default: #{default}" unless default.nil?
159
+ " #{parts.join(", ")}"
160
+ end
161
+
162
+ def alter_table_index_lines(change)
163
+ unique = change.unique ? ", unique: true" : ""
164
+ [
165
+ "",
166
+ " alter_table :#{change.table_name} do",
167
+ " add_index :#{change.field_name}#{unique}",
168
+ " end"
169
+ ]
170
+ end
171
+
65
172
  def hanami_type(attributes)
66
173
  case attributes[:type]
67
174
  when "boolean" then "TrueClass"
@@ -84,9 +191,42 @@ module BetterAuth
84
191
  end
85
192
  end
86
193
 
87
- def foreign_key_target(model, options)
88
- tables = BetterAuth::Schema.auth_tables(options)
89
- tables.fetch(model.to_s, nil)&.fetch(:model_name) || model
194
+ def foreign_key_line(command, logical_field, attributes, options_or_tables)
195
+ column = attributes[:field_name] || physical_name(logical_field)
196
+ reference = attributes.fetch(:references)
197
+ target, target_key = foreign_key_target(reference, options_or_tables)
198
+ parts = ["#{command} :#{column}, :#{target}", "type: #{hanami_type(attributes)}"]
199
+ parts << "null: false" if attributes[:required]
200
+ parts << "key: :#{target_key}" unless target_key == "id"
201
+ parts << "on_delete: :#{reference[:on_delete]}" if reference[:on_delete]
202
+ " #{parts.join(", ")}"
203
+ end
204
+
205
+ def foreign_key_target(reference, options_or_tables)
206
+ tables = auth_tables_for(options_or_tables)
207
+ model = reference.fetch(:model).to_s
208
+ table = tables.fetch(model, nil) || tables.each_value.find { |candidate| candidate.fetch(:model_name).to_s == model }
209
+ target = table&.fetch(:model_name) || model
210
+ [target, foreign_key_target_field(reference, table)]
211
+ end
212
+
213
+ def foreign_key_target_field(reference, table)
214
+ field = reference.fetch(:field).to_s
215
+ return physical_name(field) unless table
216
+
217
+ attributes = table.fetch(:fields).fetch(field, nil)
218
+ return attributes[:field_name] || physical_name(field) if attributes
219
+ return field if table.fetch(:fields).each_value.any? { |data| data[:field_name].to_s == field }
220
+
221
+ physical_name(field)
222
+ end
223
+
224
+ def auth_tables_for(options_or_tables)
225
+ if options_or_tables.is_a?(Hash) && options_or_tables.values.all? { |value| value.is_a?(Hash) && value.key?(:fields) && value.key?(:model_name) }
226
+ options_or_tables
227
+ else
228
+ BetterAuth::Schema.auth_tables(options_or_tables)
229
+ end
90
230
  end
91
231
 
92
232
  def physical_name(value)
@@ -11,7 +11,7 @@ module BetterAuth
11
11
 
12
12
  def better_auth(auth: nil, at: BetterAuth::Configuration::DEFAULT_BASE_PATH)
13
13
  mount_path = normalize_better_auth_mount_path(at)
14
- auth ||= BetterAuth::Hanami.auth(base_path: mount_path)
14
+ auth ||= auth_for_better_auth_mount(mount_path)
15
15
  app = BetterAuth::Hanami::MountedApp.new(auth, mount_path: mount_path)
16
16
 
17
17
  HTTP_METHODS.each do |method_name|
@@ -29,6 +29,13 @@ module BetterAuth
29
29
  normalized = normalized.delete_suffix("/") unless normalized == "/"
30
30
  normalized.empty? ? "/" : normalized
31
31
  end
32
+
33
+ def auth_for_better_auth_mount(mount_path)
34
+ configured_path = normalize_better_auth_mount_path(BetterAuth::Hanami.configuration.base_path)
35
+ return BetterAuth::Hanami.auth if mount_path == configured_path
36
+
37
+ BetterAuth::Hanami.auth(base_path: mount_path)
38
+ end
32
39
  end
33
40
  end
34
41
  end
@@ -10,24 +10,26 @@ module BetterAuth
10
10
  class SequelAdapter < BetterAuth::Adapters::Base
11
11
  include BetterAuth::Adapters::JoinSupport
12
12
 
13
+ WHERE_OPERATORS = %w[eq ne gt gte lt lte in not_in contains starts_with ends_with].freeze
14
+
13
15
  attr_reader :connection
14
16
 
15
- def self.from_hanami(options, container: nil)
17
+ def self.from_hanami(options, container: nil, allow_memory_fallback: false)
16
18
  if container.nil? && defined?(::Hanami) && ::Hanami.respond_to?(:app)
17
19
  container = ::Hanami.app
18
20
  end
19
- return memory_fallback(options) unless container
21
+ return memory_fallback(options, allow_memory_fallback: allow_memory_fallback) unless container
20
22
 
21
- from_container(container, options)
23
+ from_container(container, options, allow_memory_fallback: allow_memory_fallback)
22
24
  end
23
25
 
24
- def self.from_container(container, options)
26
+ def self.from_container(container, options, allow_memory_fallback: false)
25
27
  gateway = if container.respond_to?(:key?) && container.key?("db.gateway")
26
28
  container["db.gateway"]
27
29
  elsif container.respond_to?(:[]) && safe_fetch(container, "db.gateway")
28
30
  container["db.gateway"]
29
31
  end
30
- return memory_fallback(options) unless gateway
32
+ return memory_fallback(options, allow_memory_fallback: allow_memory_fallback) unless gateway
31
33
 
32
34
  connection = gateway.respond_to?(:connection) ? gateway.connection : gateway
33
35
  new(options, connection: connection)
@@ -39,7 +41,11 @@ module BetterAuth
39
41
  nil
40
42
  end
41
43
 
42
- def self.memory_fallback(options)
44
+ def self.memory_fallback(options, allow_memory_fallback: false)
45
+ if options.respond_to?(:production?) && options.production? && !allow_memory_fallback
46
+ raise Error, "Hanami db.gateway is required in production. Set config.allow_memory_fallback = true to use volatile memory storage intentionally."
47
+ end
48
+
43
49
  Kernel.warn(
44
50
  "[better_auth-hanami] SequelAdapter: using BetterAuth::Adapters::Memory " \
45
51
  "(no Hanami container or db.gateway). Persisted auth data will not survive process restart."
@@ -56,7 +62,8 @@ module BetterAuth
56
62
  model = model.to_s
57
63
  input = transform_input(model, data, "create", force_allow_id)
58
64
  table_dataset(model).insert(physical_attributes(model, input))
59
- find_one(model: model, where: [{field: "id", value: input.fetch("id")}])
65
+ lookup = create_lookup(model, input)
66
+ lookup ? find_one(model: model, where: [lookup]) : input
60
67
  end
61
68
 
62
69
  def find_one(model:, where: [], select: nil, join: nil)
@@ -67,32 +74,41 @@ module BetterAuth
67
74
  model = model.to_s
68
75
  dataset = table_dataset(model)
69
76
  dataset = apply_where(model, dataset, where || [])
70
- dataset = apply_select(model, dataset, select) if select
77
+ join_config = normalized_join(model, join)
78
+ requested_select = select ? Array(select).map { |field| storage_key(field) } : nil
79
+ effective_select = select_fields_for_join(requested_select, join_config)
80
+ dataset = apply_select(model, dataset, effective_select) if effective_select
71
81
  dataset = apply_order(model, dataset, sort_by) if sort_by
72
- dataset = dataset.limit(Integer(limit)) if limit
73
- dataset = dataset.offset(Integer(offset)) if offset
82
+ dataset = dataset.limit(coerce_pagination(limit, "limit")) if limit
83
+ dataset = dataset.offset(coerce_pagination(offset, "offset")) if offset
74
84
 
75
85
  records = dataset.all.map { |row| normalize_record(model, row) }
76
- attach_joins(model, records, join)
86
+ records = attach_joins(model, records, join_config)
87
+ trim_unrequested_select_fields(records, requested_select, join_config) if requested_select
88
+ records
77
89
  end
78
90
 
79
91
  def update(model:, where:, update:)
80
92
  model = model.to_s
81
- existing = find_one(model: model, where: where, select: ["id"])
93
+ existing = find_one(model: model, where: where)
82
94
  return nil unless existing
83
95
 
84
96
  update_many(model: model, where: where, update: update)
85
- find_one(model: model, where: [{field: "id", value: existing.fetch("id")}])
97
+ lookup = record_lookup(model, existing)
98
+ lookup ? find_one(model: model, where: [lookup]) : find_one(model: model, where: where)
86
99
  end
87
100
 
88
101
  def update_many(model:, where:, update:, returning: false)
89
102
  model = model.to_s
90
- existing = returning ? find_many(model: model, where: where, select: ["id"]) : []
103
+ existing = returning ? find_many(model: model, where: where) : []
91
104
  attributes = physical_attributes(model, transform_input(model, update, "update", true))
92
105
  apply_where(model, table_dataset(model), where || []).update(attributes)
93
106
  return unless returning
94
107
 
95
- existing.map { |record| find_one(model: model, where: [{field: "id", value: record.fetch("id")}]) }
108
+ existing.map do |record|
109
+ lookup = record_lookup(model, record)
110
+ lookup ? find_one(model: model, where: [lookup]) : record
111
+ end
96
112
  end
97
113
 
98
114
  def delete(model:, where:)
@@ -136,22 +152,34 @@ module BetterAuth
136
152
  column = storage_field(model, field)
137
153
  identifier = Sequel[column.to_sym]
138
154
  operator = (fetch_key(clause, :operator) || "eq").to_s
155
+ raise APIError.new("BAD_REQUEST", message: "Invalid operator #{operator}") unless WHERE_OPERATORS.include?(operator)
156
+
157
+ mode = (fetch_key(clause, :mode) || "sensitive").to_s
139
158
  attributes = schema_for(model).fetch(:fields).fetch(field)
140
159
  raw_value = fetch_key(clause, :value)
141
160
  value = coerce_where_value(raw_value, attributes)
161
+ insensitive = insensitive_string_mode?(mode, attributes)
162
+ comparable = insensitive ? Sequel.function(:lower, identifier) : identifier
163
+ compare_value = insensitive ? downcase_where_value(value) : value
142
164
 
143
165
  case operator
144
- when "in" then {column.to_sym => Array(raw_value).map { |entry| coerce_where_value(entry, attributes) }}
145
- when "not_in" then Sequel.~(column.to_sym => Array(raw_value).map { |entry| coerce_where_value(entry, attributes) })
146
- when "ne" then Sequel.~(column.to_sym => value)
147
- when "gt" then identifier > value
148
- when "gte" then identifier >= value
149
- when "lt" then identifier < value
150
- when "lte" then identifier <= value
151
- when "contains" then Sequel.like(identifier, "%#{escape_like(value)}%", escape: "\\")
152
- when "starts_with" then Sequel.like(identifier, "#{escape_like(value)}%", escape: "\\")
153
- when "ends_with" then Sequel.like(identifier, "%#{escape_like(value)}", escape: "\\")
154
- else {column.to_sym => value}
166
+ when "in"
167
+ values = Array(raw_value).map { |entry| coerce_where_value(entry, attributes) }
168
+ values = downcase_where_value(values) if insensitive
169
+ insensitive ? Sequel.expr(comparable => values) : {column.to_sym => values}
170
+ when "not_in"
171
+ values = Array(raw_value).map { |entry| coerce_where_value(entry, attributes) }
172
+ values = downcase_where_value(values) if insensitive
173
+ Sequel.~(insensitive ? Sequel.expr(comparable => values) : {column.to_sym => values})
174
+ when "ne" then insensitive ? Sequel.~(Sequel.expr(comparable => compare_value)) : Sequel.~(column.to_sym => value)
175
+ when "gt" then comparable > compare_value
176
+ when "gte" then comparable >= compare_value
177
+ when "lt" then comparable < compare_value
178
+ when "lte" then comparable <= compare_value
179
+ when "contains" then Sequel.like(comparable, "%#{escape_like(compare_value)}%", escape: "\\")
180
+ when "starts_with" then Sequel.like(comparable, "#{escape_like(compare_value)}%", escape: "\\")
181
+ when "ends_with" then Sequel.like(comparable, "%#{escape_like(compare_value)}", escape: "\\")
182
+ else insensitive ? Sequel.expr(comparable => compare_value) : {column.to_sym => value}
155
183
  end
156
184
  end
157
185
 
@@ -165,27 +193,50 @@ module BetterAuth
165
193
  dataset.order(direction)
166
194
  end
167
195
 
168
- def attach_joins(model, records, join)
169
- return records unless join
196
+ def attach_joins(_model, records, join_config)
197
+ return records if join_config.empty? || records.empty?
170
198
 
171
- join_config = normalized_join(model, join)
172
199
  records.each do |record|
173
200
  join_config.each do |join_model, config|
174
- record[join_model] = joined_records(record, join_model, config)
201
+ record[join_model] = one_to_one_join?(config) ? nil : []
175
202
  end
176
203
  end
204
+ join_config.each do |join_model, config|
205
+ attach_join(model_records: records, join_model: join_model, config: config)
206
+ end
177
207
  records
178
208
  end
179
209
 
210
+ def attach_join(model_records:, join_model:, config:)
211
+ values = model_records.map { |record| record[config.fetch(:from)] }.compact.uniq
212
+ return if values.empty?
213
+
214
+ joined = if one_to_one_join?(config)
215
+ find_many(model: join_model, where: [{field: config.fetch(:to), operator: "in", value: values}])
216
+ else
217
+ values.flat_map do |value|
218
+ find_many(model: join_model, where: [{field: config.fetch(:to), value: value}], limit: join_limit(config))
219
+ end
220
+ end
221
+ grouped = joined.group_by { |record| record[config.fetch(:to)] }
222
+ model_records.each do |record|
223
+ records = grouped.fetch(record[config.fetch(:from)], [])
224
+ record[join_model] = if one_to_one_join?(config)
225
+ records.first
226
+ else
227
+ records.first(join_limit(config))
228
+ end
229
+ end
230
+ end
231
+
180
232
  def joined_records(record, join_model, config)
181
233
  local_value = record[config.fetch(:from)]
182
234
  where = [{field: config.fetch(:to), value: local_value}]
183
-
184
235
  if one_to_one_join?(config)
185
236
  find_one(model: join_model, where: where)
186
237
  else
187
238
  records = find_many(model: join_model, where: where)
188
- config[:limit] ? records.first(Integer(config[:limit])) : records
239
+ records.first(join_limit(config))
189
240
  end
190
241
  end
191
242
 
@@ -213,9 +264,9 @@ module BetterAuth
213
264
  reference = attributes.fetch(:references)
214
265
  if forward_join
215
266
  unique = attributes[:unique] == true
216
- {from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
267
+ {from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique, limit: unique ? 1 : default_find_many_limit}
217
268
  else
218
- {from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
269
+ {from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true, limit: 1}
219
270
  end
220
271
  end
221
272
 
@@ -233,7 +284,7 @@ module BetterAuth
233
284
  raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
234
285
  end
235
286
 
236
- if !value_provided && action == "create" && attributes.key?(:default_value)
287
+ if action == "create" && attributes.key?(:default_value) && (!value_provided || (attributes[:required] && value.nil?))
237
288
  value = resolve_default(attributes[:default_value])
238
289
  value_provided = true
239
290
  elsif !value_provided && action == "update" && attributes[:on_update]
@@ -246,10 +297,30 @@ module BetterAuth
246
297
  output[field] = coerce_value(value, attributes) if value_provided
247
298
  end
248
299
 
249
- output["id"] = generated_id if action == "create" && !output.key?("id")
300
+ output["id"] = generated_id if action == "create" && !output.key?("id") && fields.key?("id")
250
301
  output
251
302
  end
252
303
 
304
+ def create_lookup(model, input)
305
+ fields = schema_for(model).fetch(:fields)
306
+ return {field: "id", value: input.fetch("id")} if fields.key?("id") && input.key?("id")
307
+
308
+ unique_field = fields.find { |field, attributes| attributes[:unique] && input.key?(field) }
309
+ return {field: unique_field.first, value: input.fetch(unique_field.first)} if unique_field
310
+
311
+ nil
312
+ end
313
+
314
+ def record_lookup(model, record)
315
+ fields = schema_for(model).fetch(:fields)
316
+ return {field: "id", value: record.fetch("id")} if fields.key?("id") && record.key?("id")
317
+
318
+ unique_field = fields.find { |field, attributes| attributes[:unique] && record.key?(field) }
319
+ return {field: unique_field.first, value: record.fetch(unique_field.first)} if unique_field
320
+
321
+ nil
322
+ end
323
+
253
324
  def physical_attributes(model, logical)
254
325
  logical.each_with_object({}) do |(field, value), attributes|
255
326
  attributes[storage_field(model, field).to_sym] = value
@@ -271,10 +342,16 @@ module BetterAuth
271
342
 
272
343
  def schema_for(model)
273
344
  BetterAuth::Schema.auth_tables(options).fetch(model.to_s)
345
+ rescue KeyError
346
+ raise APIError.new("BAD_REQUEST", message: "Invalid model #{model}")
274
347
  end
275
348
 
276
349
  def storage_field(model, field)
277
- schema_for(model).fetch(:fields).fetch(field.to_s).fetch(:field_name, physical_name(field))
350
+ fields = schema_for(model).fetch(:fields)
351
+ attributes = fields[field.to_s]
352
+ raise APIError.new("BAD_REQUEST", message: "Invalid field #{field} for model #{model}") unless attributes
353
+
354
+ attributes.fetch(:field_name, physical_name(field))
278
355
  end
279
356
 
280
357
  def generated_id
@@ -291,7 +368,7 @@ module BetterAuth
291
368
 
292
369
  def coerce_value(value, attributes)
293
370
  return value if value.nil?
294
- return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
371
+ return parse_time_value(value) if attributes[:type] == "date" && value.is_a?(String)
295
372
  return JSON.generate(value) if json_like?(attributes) && !value.is_a?(String)
296
373
 
297
374
  value
@@ -316,7 +393,7 @@ module BetterAuth
316
393
  when "number"
317
394
  return coerce_number(value)
318
395
  when "date"
319
- return Time.parse(value) if value.is_a?(String)
396
+ return parse_time_value(value) if value.is_a?(String)
320
397
  end
321
398
 
322
399
  coerce_value(value, attributes)
@@ -348,6 +425,57 @@ module BetterAuth
348
425
  value
349
426
  end
350
427
 
428
+ def coerce_pagination(value, label)
429
+ Integer(value).tap do |integer|
430
+ raise ArgumentError if integer.negative?
431
+ end
432
+ rescue ArgumentError, TypeError
433
+ raise APIError.new("BAD_REQUEST", message: "Invalid #{label}")
434
+ end
435
+
436
+ def select_fields_for_join(select, join_config)
437
+ return select unless select && !join_config.empty?
438
+
439
+ join_config.each_value.with_object(select.dup) do |config, fields|
440
+ from = storage_key(config.fetch(:from))
441
+ fields << from unless fields.include?(from)
442
+ end
443
+ end
444
+
445
+ def trim_unrequested_select_fields(records, requested_select, join_config)
446
+ hidden = join_config.each_value.map { |config| storage_key(config.fetch(:from)) } - requested_select
447
+ records.each { |record| hidden.each { |field| record.delete(field) } }
448
+ end
449
+
450
+ def insensitive_string_mode?(mode, attributes)
451
+ mode == "insensitive" && attributes[:type].to_s == "string"
452
+ end
453
+
454
+ def downcase_where_value(value)
455
+ return value.map { |entry| downcase_where_value(entry) } if value.is_a?(Array)
456
+ return value.downcase if value.is_a?(String)
457
+
458
+ value
459
+ end
460
+
461
+ def default_find_many_limit
462
+ database_options = options.advanced[:database] || {}
463
+ coerce_pagination(
464
+ database_options[:default_find_many_limit] || database_options[:defaultFindManyLimit] || 100,
465
+ "limit"
466
+ )
467
+ end
468
+
469
+ def join_limit(config)
470
+ coerce_pagination(config[:limit] || default_find_many_limit, "limit")
471
+ end
472
+
473
+ def parse_time_value(value)
474
+ Time.parse(value)
475
+ rescue ArgumentError
476
+ raise APIError.new("BAD_REQUEST", message: "Invalid date")
477
+ end
478
+
351
479
  def escape_like(value)
352
480
  value.to_s.gsub(/[\\%_]/) { |match| "\\#{match}" }
353
481
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Hanami
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
@@ -20,16 +20,24 @@ module BetterAuth
20
20
  end
21
21
 
22
22
  def configure
23
- yield configuration
24
- @auth = nil
23
+ auth_mutex.synchronize do
24
+ yield configuration
25
+ @auth = nil
26
+ end
25
27
  end
26
28
 
27
29
  def auth(overrides = nil)
28
30
  options = configuration.to_auth_options
29
- return @auth ||= BetterAuth.auth(options) if overrides.nil? || overrides.empty?
31
+ return auth_mutex.synchronize { @auth ||= BetterAuth.auth(options) } if overrides.nil? || overrides.empty?
30
32
 
31
33
  BetterAuth.auth(options.merge(overrides))
32
34
  end
35
+
36
+ private
37
+
38
+ def auth_mutex
39
+ @auth_mutex ||= Mutex.new
40
+ end
33
41
  end
34
42
  end
35
43
  end
@@ -19,4 +19,11 @@ namespace :better_auth do
19
19
  BetterAuth::Hanami::Generators::RelationGenerator.new.run
20
20
  end
21
21
  end
22
+
23
+ desc "Check Better Auth configuration and schema health"
24
+ task :doctor do
25
+ config = BetterAuth::Configuration.new(BetterAuth::Hanami.configuration.to_auth_options)
26
+ exit_code = BetterAuth::Doctor.print(BetterAuth::Doctor.check(config), stdout: $stdout, stderr: $stderr)
27
+ abort if exit_code != 0
28
+ end
22
29
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-hanami
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -206,14 +206,14 @@ files:
206
206
  - lib/better_auth/hanami/version.rb
207
207
  - lib/better_auth_hanami.rb
208
208
  - lib/tasks/better_auth.rake
209
- homepage: https://github.com/sebasxsala/better-auth
209
+ homepage: https://github.com/sebasxsala/better-auth-rb
210
210
  licenses:
211
211
  - MIT
212
212
  metadata:
213
- homepage_uri: https://github.com/sebasxsala/better-auth
214
- source_code_uri: https://github.com/sebasxsala/better-auth
215
- changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-hanami/CHANGELOG.md
216
- bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
213
+ homepage_uri: https://github.com/sebasxsala/better-auth-rb
214
+ source_code_uri: https://github.com/sebasxsala/better-auth-rb
215
+ changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth-hanami/CHANGELOG.md
216
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
217
217
  rdoc_options: []
218
218
  require_paths:
219
219
  - lib