better_auth-hanami 0.5.0 → 0.7.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: c1c6552c515f275dd9c70eefc5177892d0717aae7bf133ef8042eca0b5033f19
4
- data.tar.gz: e70020d52e489d99047e09f254872e84c118c3faaf1d957f107649545eaef92e
3
+ metadata.gz: e621a185cce9796daa7123db9fda9e9794c1a6ea69320c3d0f22f0e302dfeead
4
+ data.tar.gz: b016d7df8af71a441b731af82d141cb3b8e63e96c2ebf4297f617b6a32587ed6
5
5
  SHA512:
6
- metadata.gz: 72cf74b3d30eb60e13b85d8340e24c062fe46bc49f65e8816599b065f85f89265de7a60064e27b3d163d041855f5e68f208750a9693b1e8691e8bfca25204aac
7
- data.tar.gz: 2d19ac81d33d23098ff8bb367ea658517c2a76a2f67d2d557b78ddd1410766411d506052626d189d8d80afdc235a7e25bada1587e732e6cb5146d24f5d653e58
6
+ metadata.gz: 9c47fa9eb76a9196f1a7a1179b2fb58359fb49e2b54789f14d42a9617d6a3e890c1c63522c2d27c805f0eabb27e38bbfc09db86bb1c0869705ac847c16a7899f
7
+ data.tar.gz: 91165382044cc8d14c8d6fc044930ca9a896cecb3d7f35b2fcd3c9f076a8841eefaff1fd6e6cdc06e0a25c4b1d7c0d3b0c1ed6e0e96ff12eeb453dcfb809ffe8
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.7.0] - 2026-05-05
11
+
12
+ ### Fixed
13
+
14
+ - Aligned Hanami route mounting, action helpers, install generator, and migration generator behavior with the shared Rack and schema semantics.
15
+ - Hardened the Sequel adapter for upstream-shaped filtering, joins, falsey values, and limit behavior.
16
+
10
17
  ## [0.1.1] - 2026-04-29
11
18
 
12
19
  ### Fixed
data/README.md CHANGED
@@ -65,6 +65,7 @@ Hanami.app.register_provider(:better_auth) do
65
65
  config.database = ->(options) {
66
66
  BetterAuth::Hanami::SequelAdapter.from_container(target, options)
67
67
  }
68
+ config.trusted_origins = [target["settings"].better_auth_url].compact
68
69
  config.email_and_password = {enabled: true}
69
70
  config.plugins = []
70
71
  end
@@ -76,6 +77,37 @@ Hanami.app.register_provider(:better_auth) do
76
77
  end
77
78
  ```
78
79
 
80
+ `trusted_origins` controls Better Auth origin and redirect URL validation. If
81
+ your browser client calls the auth endpoints from another origin, configure Rack
82
+ CORS middleware in your Hanami app as well so preflight requests and
83
+ `Access-Control-*` response headers match your frontend origin and credentials
84
+ policy. For the shared Rack/CORS/CSRF boundary, see
85
+ [`host-app-responsibilities.md`](../../.docs/features/host-app-responsibilities.md).
86
+
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
89
+ `BetterAuth::Hanami::MountedApp` behavior aligned with Hanami's router instead
90
+ of copying Rails mount internals without integration tests. Be cautious with
91
+ relation or inflector overrides generated for an app, because overwriting
92
+ application-specific Hanami relations can be destructive.
93
+
94
+ ## Regenerating Migrations
95
+
96
+ The migration generator skips an existing `*_create_better_auth_tables.rb` file
97
+ by default so user-edited migrations are not overwritten. To intentionally
98
+ regenerate the base migration for a new app or after changing plugin schemas,
99
+ call the Ruby API with `force: true`:
100
+
101
+ ```ruby
102
+ BetterAuth::Hanami::Generators::MigrationGenerator.new.run(force: true)
103
+ ```
104
+
105
+ The generated rake task keeps the non-overwriting behavior:
106
+
107
+ ```bash
108
+ bundle exec rake better_auth:generate:migration
109
+ ```
110
+
79
111
  ## Routes
80
112
 
81
113
  The generated `config/routes.rb` includes:
@@ -98,6 +130,11 @@ By default this mounts Better Auth at `/api/auth`. Customize the path:
98
130
  better_auth at: "/auth"
99
131
  ```
100
132
 
133
+ `BetterAuth::Hanami::MountedApp` expects `PATH_INFO` in the shape produced by
134
+ Hanami's router; see `spec/better_auth/hanami/routing_spec.rb`. Custom Rack
135
+ stacks with different `SCRIPT_NAME` conventions may need application-level path
136
+ rewriting.
137
+
101
138
  ## Action Helpers
102
139
 
103
140
  Include helpers in your base action:
@@ -34,6 +34,9 @@ module BetterAuth
34
34
  end
35
35
 
36
36
  def resolve_better_auth_session(request)
37
+ auth = BetterAuth::Hanami.auth
38
+ auth.context.prepare_for_request!(request) if auth.context.respond_to?(:prepare_for_request!)
39
+
37
40
  context = BetterAuth::Endpoint::Context.new(
38
41
  path: request_path(request),
39
42
  method: request_method(request),
@@ -41,7 +44,7 @@ module BetterAuth
41
44
  body: {},
42
45
  params: {},
43
46
  headers: {"cookie" => request_cookie(request)},
44
- context: BetterAuth::Hanami.auth.context,
47
+ context: auth.context,
45
48
  request: request
46
49
  )
47
50
  BetterAuth::Session.find_current(context, disable_refresh: true)
@@ -43,10 +43,14 @@ module BetterAuth
43
43
 
44
44
  def update_routes
45
45
  path = File.join(destination_root, "config/routes.rb")
46
- return unless File.exist?(path)
46
+ unless File.exist?(path)
47
+ Kernel.warn("[better_auth-hanami] InstallGenerator: #{path} not found; skipping routes wiring. Add Hanami routes manually.")
48
+ return
49
+ end
47
50
 
48
51
  content = File.read(path)
49
52
  content = content.gsub(%(require "better_auth/hanami/routing"), %(require "better_auth/hanami"))
53
+ content = dedupe_better_auth_requires(content)
50
54
  content = %(require "better_auth/hanami"\n) + content unless content.include?(%("better_auth/hanami"))
51
55
  content = content.sub("class Routes < Hanami::Routes\n", "class Routes < Hanami::Routes\n include BetterAuth::Hanami::Routing\n") unless content.include?("include BetterAuth::Hanami::Routing")
52
56
  content = content.sub(/(include BetterAuth::Hanami::Routing\n)(?!\s*better_auth)/, "\\1 better_auth\n") unless content.match?(/^\s*better_auth\b/)
@@ -55,7 +59,10 @@ module BetterAuth
55
59
 
56
60
  def update_settings
57
61
  path = File.join(destination_root, "config/settings.rb")
58
- return unless File.exist?(path)
62
+ unless File.exist?(path)
63
+ Kernel.warn("[better_auth-hanami] InstallGenerator: #{path} not found; skipping settings wiring. Add better_auth_secret and better_auth_url manually.")
64
+ return
65
+ end
59
66
 
60
67
  content = File.read(path)
61
68
  return if content.include?("setting :better_auth_secret")
@@ -64,10 +71,21 @@ module BetterAuth
64
71
  " setting :better_auth_secret, constructor: Types::String.constrained(min_size: 32)",
65
72
  " setting :better_auth_url, constructor: Types::String.optional"
66
73
  ].join("\n")
67
- content = content.sub("class Settings < Hanami::Settings\n", "class Settings < Hanami::Settings\n#{insertion}\n")
74
+ content = content.sub(/(class[ \t]+Settings[ \t]*<[ \t]*Hanami::Settings[ \t]*\n)/, "\\1#{insertion}\n")
68
75
  File.write(path, content)
69
76
  end
70
77
 
78
+ def dedupe_better_auth_requires(content)
79
+ previous = nil
80
+ content.lines.each_with_object([]) do |line, output|
81
+ stripped = line.strip
82
+ next if stripped == previous && stripped == %(require "better_auth/hanami")
83
+
84
+ output << line
85
+ previous = stripped
86
+ end.join
87
+ end
88
+
71
89
  def provider_template
72
90
  <<~RUBY
73
91
  # frozen_string_literal: true
@@ -7,25 +7,28 @@ module BetterAuth
7
7
  module Hanami
8
8
  module Generators
9
9
  class MigrationGenerator
10
- def initialize(destination_root: Dir.pwd, configuration: nil)
10
+ def initialize(destination_root: Dir.pwd, configuration: nil, force: false)
11
11
  @destination_root = destination_root
12
12
  @configuration = configuration
13
+ @force = force
13
14
  end
14
15
 
15
- def run
16
- return migration_path if existing_migration?
16
+ def run(force: nil)
17
+ force = @force if force.nil?
18
+ return existing_migration_path if existing_migration_path && !force
17
19
 
18
- FileUtils.mkdir_p(File.dirname(migration_path))
19
- File.write(migration_path, BetterAuth::Hanami::Migration.render(generator_config))
20
- migration_path
20
+ path = existing_migration_path || migration_path
21
+ FileUtils.mkdir_p(File.dirname(path))
22
+ File.write(path, BetterAuth::Hanami::Migration.render(generator_config))
23
+ path
21
24
  end
22
25
 
23
26
  private
24
27
 
25
28
  attr_reader :destination_root, :configuration
26
29
 
27
- def existing_migration?
28
- Dir[File.join(destination_root, "config/db/migrate/*_create_better_auth_tables.rb")].any?
30
+ def existing_migration_path
31
+ @existing_migration_path ||= Dir[File.join(destination_root, "config/db/migrate/*_create_better_auth_tables.rb")].min
29
32
  end
30
33
 
31
34
  def migration_path
@@ -2,6 +2,11 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Hanami
5
+ # Rewrites PATH_INFO so the core router sees paths under +mount_path+.
6
+ # Hanami's +Slice::Router+ passes PATH_INFO as exercised in routing specs;
7
+ # custom Rack mounts that differ from that contract may need app-level
8
+ # rewriting adjustments. Compare the Rails adapter when debugging path
9
+ # behavior involving SCRIPT_NAME.
5
10
  class MountedApp
6
11
  def initialize(auth, mount_path:)
7
12
  @auth = auth
@@ -1,19 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require "json"
4
5
  require "time"
5
6
  require "sequel"
6
7
 
7
8
  module BetterAuth
8
9
  module Hanami
9
10
  class SequelAdapter < BetterAuth::Adapters::Base
11
+ include BetterAuth::Adapters::JoinSupport
12
+
10
13
  attr_reader :connection
11
14
 
12
15
  def self.from_hanami(options, container: nil)
13
16
  if container.nil? && defined?(::Hanami) && ::Hanami.respond_to?(:app)
14
17
  container = ::Hanami.app
15
18
  end
16
- return BetterAuth::Adapters::Memory.new(options) unless container
19
+ return memory_fallback(options) unless container
17
20
 
18
21
  from_container(container, options)
19
22
  end
@@ -24,7 +27,7 @@ module BetterAuth
24
27
  elsif container.respond_to?(:[]) && safe_fetch(container, "db.gateway")
25
28
  container["db.gateway"]
26
29
  end
27
- return BetterAuth::Adapters::Memory.new(options) unless gateway
30
+ return memory_fallback(options) unless gateway
28
31
 
29
32
  connection = gateway.respond_to?(:connection) ? gateway.connection : gateway
30
33
  new(options, connection: connection)
@@ -36,6 +39,14 @@ module BetterAuth
36
39
  nil
37
40
  end
38
41
 
42
+ def self.memory_fallback(options)
43
+ Kernel.warn(
44
+ "[better_auth-hanami] SequelAdapter: using BetterAuth::Adapters::Memory " \
45
+ "(no Hanami container or db.gateway). Persisted auth data will not survive process restart."
46
+ )
47
+ BetterAuth::Adapters::Memory.new(options)
48
+ end
49
+
39
50
  def initialize(options, connection:)
40
51
  super(options)
41
52
  @connection = connection
@@ -125,19 +136,21 @@ module BetterAuth
125
136
  column = storage_field(model, field)
126
137
  identifier = Sequel[column.to_sym]
127
138
  operator = (fetch_key(clause, :operator) || "eq").to_s
128
- value = fetch_key(clause, :value)
139
+ attributes = schema_for(model).fetch(:fields).fetch(field)
140
+ raw_value = fetch_key(clause, :value)
141
+ value = coerce_where_value(raw_value, attributes)
129
142
 
130
143
  case operator
131
- when "in" then {column.to_sym => Array(value)}
132
- when "not_in" then Sequel.~(column.to_sym => Array(value))
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) })
133
146
  when "ne" then Sequel.~(column.to_sym => value)
134
147
  when "gt" then identifier > value
135
148
  when "gte" then identifier >= value
136
149
  when "lt" then identifier < value
137
150
  when "lte" then identifier <= value
138
- when "contains" then Sequel.like(identifier, "%#{value}%")
139
- when "starts_with" then Sequel.like(identifier, "#{value}%")
140
- when "ends_with" then Sequel.like(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: "\\")
141
154
  else {column.to_sym => value}
142
155
  end
143
156
  end
@@ -155,20 +168,57 @@ module BetterAuth
155
168
  def attach_joins(model, records, join)
156
169
  return records unless join
157
170
 
171
+ join_config = normalized_join(model, join)
158
172
  records.each do |record|
159
- join.each_key do |join_model|
160
- join_model = join_model.to_s
161
- case [model.to_s, join_model]
162
- when ["session", "user"], ["account", "user"]
163
- record[join_model] = find_one(model: join_model, where: [{field: "id", value: record["userId"]}])
164
- when ["user", "account"]
165
- record[join_model] = find_many(model: "account", where: [{field: "userId", value: record["id"]}])
166
- end
173
+ join_config.each do |join_model, config|
174
+ record[join_model] = joined_records(record, join_model, config)
167
175
  end
168
176
  end
169
177
  records
170
178
  end
171
179
 
180
+ def joined_records(record, join_model, config)
181
+ local_value = record[config.fetch(:from)]
182
+ where = [{field: config.fetch(:to), value: local_value}]
183
+
184
+ if one_to_one_join?(config)
185
+ find_one(model: join_model, where: where)
186
+ else
187
+ records = find_many(model: join_model, where: where)
188
+ config[:limit] ? records.first(Integer(config[:limit])) : records
189
+ end
190
+ end
191
+
192
+ def one_to_one_join?(config)
193
+ config[:relation] == "one-to-one" || config[:unique] == true
194
+ end
195
+
196
+ def inferred_join_config(model, join_model)
197
+ foreign_keys = schema_for(join_model).fetch(:fields).select do |_field, attributes|
198
+ reference_model_matches?(attributes, model)
199
+ end
200
+ forward_join = true
201
+
202
+ if foreign_keys.empty?
203
+ foreign_keys = schema_for(model).fetch(:fields).select do |_field, attributes|
204
+ reference_model_matches?(attributes, join_model)
205
+ end
206
+ forward_join = false
207
+ end
208
+
209
+ raise Error, "No foreign key found for model #{join_model} and base model #{model} while performing join operation." if foreign_keys.empty?
210
+ raise Error, "Multiple foreign keys found for model #{join_model} and base model #{model} while performing join operation. Only one foreign key is supported." if foreign_keys.length > 1
211
+
212
+ foreign_key, attributes = foreign_keys.first
213
+ reference = attributes.fetch(:references)
214
+ if forward_join
215
+ unique = attributes[:unique] == true
216
+ {from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
217
+ else
218
+ {from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
219
+ end
220
+ end
221
+
172
222
  def transform_input(model, data, action, force_allow_id)
173
223
  fields = schema_for(model).fetch(:fields)
174
224
  input = stringify_keys(data)
@@ -242,6 +292,7 @@ module BetterAuth
242
292
  def coerce_value(value, attributes)
243
293
  return value if value.nil?
244
294
  return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
295
+ return JSON.generate(value) if json_like?(attributes) && !value.is_a?(String)
245
296
 
246
297
  value
247
298
  end
@@ -250,10 +301,37 @@ module BetterAuth
250
301
  return value if value.nil?
251
302
  return coerce_boolean(value) if attributes[:type] == "boolean"
252
303
  return Time.parse(value.to_s) if attributes[:type] == "date" && !value.is_a?(Time)
304
+ return parse_json_value(value) if json_like?(attributes) && value.is_a?(String)
253
305
 
254
306
  value
255
307
  end
256
308
 
309
+ def coerce_where_value(value, attributes)
310
+ return value if value.nil?
311
+
312
+ case attributes[:type]
313
+ when "boolean"
314
+ return coerce_value(false, attributes) if value == false || value == 0 || value.to_s.downcase == "false" || value.to_s == "0"
315
+ return coerce_value(true, attributes) if value == true || value == 1 || value.to_s.downcase == "true" || value.to_s == "1"
316
+ when "number"
317
+ return coerce_number(value)
318
+ when "date"
319
+ return Time.parse(value) if value.is_a?(String)
320
+ end
321
+
322
+ coerce_value(value, attributes)
323
+ end
324
+
325
+ def json_like?(attributes)
326
+ %w[json string[] number[]].include?(attributes[:type])
327
+ end
328
+
329
+ def parse_json_value(value)
330
+ JSON.parse(value)
331
+ rescue JSON::ParserError
332
+ value
333
+ end
334
+
257
335
  def coerce_boolean(value)
258
336
  return value if value == true || value == false
259
337
  return false if value == 0 || value.to_s == "0" || value.to_s.downcase == "f" || value.to_s.downcase == "false"
@@ -262,6 +340,18 @@ module BetterAuth
262
340
  value
263
341
  end
264
342
 
343
+ def coerce_number(value)
344
+ return value unless value.is_a?(String)
345
+ return value.to_i if /\A-?\d+\z/.match?(value)
346
+ return value.to_f if /\A-?\d+\.\d+\z/.match?(value)
347
+
348
+ value
349
+ end
350
+
351
+ def escape_like(value)
352
+ value.to_s.gsub(/[\\%_]/) { |match| "\\#{match}" }
353
+ end
354
+
265
355
  def stringify_keys(data)
266
356
  data.each_with_object({}) do |(key, value), result|
267
357
  result[storage_key(key)] = value
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Hanami
5
- VERSION = "0.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala