better_auth 0.10.0 → 0.11.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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -36,6 +36,34 @@ module BetterAuth
36
36
  lookup ? find_one(model: model, where: [lookup]) : input
37
37
  end
38
38
 
39
+ def create_if_absent(model:, data:, conflict_field: "id", force_allow_id: true)
40
+ model = model.to_s
41
+ field = atomic_schema_field(schema_for(model).fetch(:fields), conflict_field)
42
+ input = transform_input(model, data, "create", force_allow_id)
43
+ raise APIError.new("BAD_REQUEST", message: "Missing conflict field #{conflict_field}") unless input.key?(field)
44
+
45
+ columns = input.keys.map { |key| storage_field(model, key) }
46
+ params = input.values
47
+ values = params.each_index.map { |index| placeholder(index + 1) }.join(", ")
48
+ table = quote(table_for(model))
49
+ conflict_column = quote(storage_field(model, field))
50
+ quoted_columns = columns.map { |column| quote(column) }.join(", ")
51
+
52
+ case dialect
53
+ when :postgres, :sqlite
54
+ sql = "INSERT INTO #{table} (#{quoted_columns}) VALUES (#{values}) ON CONFLICT (#{conflict_column}) DO NOTHING RETURNING #{conflict_column}"
55
+ !execute(sql, params).empty?
56
+ when :mysql
57
+ transaction { create_if_absent_mysql(model, field, input, table, quoted_columns, values, params) }
58
+ when :mssql
59
+ sql = "INSERT INTO #{table} (#{quoted_columns}) OUTPUT inserted.#{conflict_column} " \
60
+ "SELECT #{values} WHERE NOT EXISTS (SELECT 1 FROM #{table} WITH (UPDLOCK, HOLDLOCK) WHERE #{conflict_column} = #{placeholder(params.length + 1)})"
61
+ !execute(sql, params + [input.fetch(field)]).empty?
62
+ else
63
+ raise NotImplementedError, "create_if_absent is unsupported for #{dialect}"
64
+ end
65
+ end
66
+
39
67
  def find_one(model:, where: [], select: nil, join: nil)
40
68
  if collection_join?(model.to_s, join)
41
69
  find_many(model: model, where: where, select: select, join: join).first
@@ -64,6 +92,8 @@ module BetterAuth
64
92
 
65
93
  def update(model:, where:, update:)
66
94
  model = model.to_s
95
+ return nil if Array(where).empty?
96
+
67
97
  ensure_update_input_has_fields!(model, update)
68
98
  if dialect == :postgres
69
99
  records = update_many(model: model, where: where, update: update, returning: true)
@@ -73,7 +103,9 @@ module BetterAuth
73
103
  existing = find_one(model: model, where: where)
74
104
  return nil unless existing
75
105
 
76
- update_many(model: model, where: where, update: update)
106
+ updated_count = update_many(model: model, where: where, update: update)
107
+ return nil unless updated_count.to_i.positive?
108
+
77
109
  lookup = record_lookup(model, existing)
78
110
  lookup ? find_one(model: model, where: [lookup]) : find_one(model: model, where: where)
79
111
  end
@@ -106,13 +138,14 @@ module BetterAuth
106
138
  nil
107
139
  end
108
140
 
109
- def delete_many(model:, where:)
141
+ def delete_many(model:, where:, limit: nil)
110
142
  model = model.to_s
111
143
  params = []
112
144
  where_sql = build_where(model, where || [], params)
113
145
  sql = +"DELETE FROM "
114
146
  sql << quote(table_for(model))
115
147
  sql << " WHERE #{where_sql}" unless where_sql.empty?
148
+ sql << " LIMIT #{Integer(limit)}" if limit && dialect == :mysql
116
149
  result = execute(sql, params, affected_rows_result: true)
117
150
  affected_rows(result)
118
151
  end
@@ -128,10 +161,37 @@ module BetterAuth
128
161
  (row["count"] || row[:count] || 0).to_i
129
162
  end
130
163
 
164
+ def consume_one(model:, where:)
165
+ model = model.to_s
166
+ case dialect
167
+ when :postgres, :sqlite
168
+ consume_one_with_returning(model, where)
169
+ when :mssql
170
+ consume_one_with_output(model, where)
171
+ else
172
+ transaction { consume_one_with_lock(model, where) }
173
+ end
174
+ end
175
+
176
+ def increment_one(model:, where:, increment:, set: nil, allow_server_managed: false)
177
+ model = model.to_s
178
+ increments, assignments = normalize_atomic_update(model, increment, set, allow_server_managed)
179
+ case dialect
180
+ when :postgres, :sqlite
181
+ increment_one_with_returning(model, where, increments, assignments)
182
+ when :mssql
183
+ increment_one_with_output(model, where, increments, assignments)
184
+ else
185
+ transaction { increment_one_with_lock(model, where, increments, assignments) }
186
+ end
187
+ end
188
+
131
189
  def transaction
190
+ return yield active_transaction_adapter if active_transaction_adapter
191
+
132
192
  @connection_lock.synchronize do
133
193
  execute("BEGIN", [])
134
- result = yield self
194
+ result = with_transaction_context(self) { yield self }
135
195
  execute("COMMIT", [])
136
196
  result
137
197
  rescue
@@ -142,6 +202,176 @@ module BetterAuth
142
202
 
143
203
  private
144
204
 
205
+ def consume_one_with_returning(model, where)
206
+ params = []
207
+ where_sql = build_where(model, where || [], params)
208
+ lookup = quote(storage_field(model, atomic_lookup_field(model)))
209
+ lock = (dialect == :postgres) ? " FOR UPDATE" : ""
210
+ table = quote(table_for(model))
211
+ sql = "DELETE FROM #{table} WHERE #{lookup} IN (SELECT #{lookup} FROM #{table}"
212
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
213
+ sql << " LIMIT 1#{lock}) RETURNING *"
214
+ normalize_record(model, execute(sql, params).first)
215
+ end
216
+
217
+ def consume_one_with_output(model, where)
218
+ params = []
219
+ where_sql = build_where(model, where || [], params)
220
+ sql = "DELETE TOP (1) FROM #{quote(table_for(model))} OUTPUT deleted.*"
221
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
222
+ normalize_record(model, execute(sql, params).first)
223
+ end
224
+
225
+ def consume_one_with_lock(model, where)
226
+ target = select_atomic_target(model, where)
227
+ return nil unless target
228
+
229
+ lookup = record_lookup(model, target)
230
+ raise Error, "#{self.class} cannot atomically identify a #{model} row" unless lookup
231
+
232
+ deleted = delete_many(model: model, where: [lookup], limit: 1)
233
+ ensure_numeric_affected_rows!(deleted, "delete_many")
234
+ deleted.positive? ? target : nil
235
+ end
236
+
237
+ def increment_one_with_returning(model, where, increments, assignments)
238
+ params = []
239
+ assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
240
+ where_sql = build_where(model, where || [], params)
241
+ lookup = quote(storage_field(model, atomic_lookup_field(model)))
242
+ lock = (dialect == :postgres) ? " FOR UPDATE" : ""
243
+ table = quote(table_for(model))
244
+ sql = "UPDATE #{table} SET #{assignment_sql} WHERE #{lookup} IN (SELECT #{lookup} FROM #{table}"
245
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
246
+ sql << " LIMIT 1#{lock}) RETURNING *"
247
+ normalize_record(model, execute(sql, params).first)
248
+ end
249
+
250
+ def increment_one_with_output(model, where, increments, assignments)
251
+ params = []
252
+ assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
253
+ where_sql = build_where(model, where || [], params)
254
+ sql = "UPDATE TOP (1) #{quote(table_for(model))} SET #{assignment_sql} OUTPUT inserted.*"
255
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
256
+ normalize_record(model, execute(sql, params).first)
257
+ end
258
+
259
+ def increment_one_with_lock(model, where, increments, assignments)
260
+ target = select_atomic_target(model, where)
261
+ return nil unless target
262
+
263
+ lookup = record_lookup(model, target)
264
+ raise Error, "#{self.class} cannot atomically identify a #{model} row" unless lookup
265
+
266
+ params = []
267
+ assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
268
+ guarded_where = Array(where) + [lookup.merge(connector: "AND")]
269
+ where_sql = build_where(model, guarded_where, params)
270
+ sql = "UPDATE #{quote(table_for(model))} SET #{assignment_sql} WHERE #{where_sql} LIMIT 1"
271
+ affected = affected_rows(execute(sql, params, affected_rows_result: true))
272
+ ensure_numeric_affected_rows!(affected, "update")
273
+ find_one(model: model, where: [lookup])
274
+ end
275
+
276
+ def select_atomic_target(model, where)
277
+ params = []
278
+ where_sql = build_where(model, where || [], params)
279
+ sql = "SELECT #{select_sql(model, nil, nil)} FROM #{quote(table_for(model))}"
280
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
281
+ sql << " LIMIT 1 FOR UPDATE"
282
+ normalize_record(model, execute(sql, params).first)
283
+ end
284
+
285
+ def normalize_atomic_update(model, increment, set, allow_server_managed)
286
+ raise APIError.new("BAD_REQUEST", message: "increment must be a Hash") unless increment.is_a?(Hash)
287
+
288
+ fields = schema_for(model).fetch(:fields)
289
+ increments = increment.each_with_object({}) do |(field, delta), result|
290
+ logical_field = atomic_schema_field(fields, field)
291
+ attributes = fields[logical_field]
292
+ valid_field = attributes && logical_field != "id" && attributes[:type] == "number"
293
+ valid_field = false if attributes && attributes[:input] == false && allow_server_managed != true
294
+ unless valid_field
295
+ raise APIError.new("BAD_REQUEST", message: "Invalid increment field #{field}; expected a mutable numeric field")
296
+ end
297
+ if !delta.is_a?(Numeric) || (delta.respond_to?(:finite?) && !delta.finite?)
298
+ raise APIError.new("BAD_REQUEST", message: "Increment delta for #{field} must be numeric")
299
+ end
300
+
301
+ result[logical_field] = delta
302
+ end
303
+ assignments = if set.nil? || set.empty?
304
+ {}
305
+ else
306
+ ensure_update_input_has_fields!(model, set)
307
+ transform_input(model, set, "update", true)
308
+ end
309
+ increments.reject! { |field, _delta| assignments.key?(field) }
310
+ if increments.empty? && assignments.empty?
311
+ raise APIError.new("BAD_REQUEST", message: "increment_one requires a non-empty increment or set")
312
+ end
313
+
314
+ [increments, assignments]
315
+ end
316
+
317
+ def atomic_assignments_sql(model, increments, assignments, params)
318
+ increment_sql = increments.map do |field, delta|
319
+ column = quote(storage_field(model, field))
320
+ params << delta
321
+ "#{column} = COALESCE(#{column}, 0) + #{placeholder(params.length)}"
322
+ end
323
+ set_sql = assignments.map do |field, value|
324
+ params << value
325
+ "#{quote(storage_field(model, field))} = #{placeholder(params.length)}"
326
+ end
327
+ (increment_sql + set_sql).join(", ")
328
+ end
329
+
330
+ def atomic_schema_field(fields, field)
331
+ candidate = storage_key(field)
332
+ return candidate if fields.key?(candidate)
333
+
334
+ fields.find { |logical, attributes| storage_key(attributes[:field_name] || logical) == candidate }&.first
335
+ end
336
+
337
+ def create_if_absent_mysql(model, field, input, table, columns, values, params)
338
+ savepoint = "better_auth_create_if_absent"
339
+ execute("SAVEPOINT #{savepoint}", [])
340
+ begin
341
+ execute("INSERT INTO #{table} (#{columns}) VALUES (#{values})", params)
342
+ execute("RELEASE SAVEPOINT #{savepoint}", [])
343
+ true
344
+ rescue => error
345
+ execute("ROLLBACK TO SAVEPOINT #{savepoint}", [])
346
+ execute("RELEASE SAVEPOINT #{savepoint}", [])
347
+ existing = find_one(model: model, where: [{field: field, value: input.fetch(field)}])
348
+ return false if mysql_duplicate_error?(error) && existing
349
+
350
+ raise error
351
+ end
352
+ end
353
+
354
+ def mysql_duplicate_error?(error)
355
+ (error.respond_to?(:error_number) && error.error_number == 1062) ||
356
+ (error.respond_to?(:errno) && error.errno == 1062)
357
+ end
358
+
359
+ def atomic_lookup_field(model)
360
+ fields = schema_for(model).fetch(:fields)
361
+ return "id" if fields.key?("id")
362
+
363
+ unique = fields.find { |_field, attributes| attributes[:unique] }
364
+ return unique.first if unique
365
+
366
+ raise Error, "#{self.class} cannot atomically identify a #{model} row without an id or unique field"
367
+ end
368
+
369
+ def ensure_numeric_affected_rows!(value, operation)
370
+ return value if value.is_a?(Numeric)
371
+
372
+ raise Error, "#{self.class} returned a non-numeric affected-row result from #{operation}"
373
+ end
374
+
145
375
  def transform_input(model, data, action, force_allow_id)
146
376
  fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
147
377
  input = stringify_keys(data)
@@ -266,46 +496,51 @@ module BetterAuth
266
496
  end
267
497
 
268
498
  def build_where(model, where, params)
269
- Array(where).each_with_index.map do |clause, index|
270
- field = storage_key(fetch_key(clause, :field))
271
- column = "#{quote(table_for(model))}.#{quote(storage_field(model, field))}"
272
- operator = (fetch_key(clause, :operator) || "eq").to_s
273
- value = fetch_key(clause, :value)
274
- attributes = schema_for(model).fetch(:fields).fetch(field)
275
- insensitive = insensitive_string_predicate?(clause, attributes)
276
- predicate_column = insensitive ? "LOWER(#{column})" : column
277
-
278
- expression = if value.nil? && %w[eq ne].include?(operator)
279
- null_operator = (operator == "ne") ? "IS NOT NULL" : "IS NULL"
280
- "#{column} #{null_operator}"
281
- else
282
- case operator
283
- when "in", "not_in"
284
- values = Array(value).map { |entry| insensitive ? entry.to_s.downcase : coerce_where_value(entry, attributes) }
285
- placeholders = values.map do |entry|
286
- params << entry
287
- placeholder(params.length)
288
- end.join(", ")
289
- sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
290
- "#{predicate_column} #{sql_operator} (#{placeholders})"
291
- when "contains", "starts_with", "ends_with"
292
- escaped = escape_like(insensitive ? value.to_s.downcase : value)
293
- pattern = case operator
294
- when "starts_with" then "#{escaped}%"
295
- when "ends_with" then "%#{escaped}"
296
- else "%#{escaped}%"
297
- end
298
- params << pattern
299
- "#{predicate_column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
300
- else
301
- params << (insensitive ? value.to_s.downcase : coerce_where_value(value, attributes))
302
- "#{predicate_column} #{sql_operator(operator)} #{placeholder(params.length)}"
499
+ and_clauses, or_clauses = grouped_where_clauses(where)
500
+ and_sql = and_clauses.map { |clause| build_where_clause(model, clause, params) }
501
+ or_sql = or_clauses.map { |clause| build_where_clause(model, clause, params) }
502
+ return and_sql.join(" AND ") if or_sql.empty?
503
+ return or_sql.join(" OR ") if and_sql.empty?
504
+
505
+ "(#{and_sql.join(" AND ")}) AND (#{or_sql.join(" OR ")})"
506
+ end
507
+
508
+ def build_where_clause(model, clause, params)
509
+ field = storage_key(fetch_key(clause, :field))
510
+ column = "#{quote(table_for(model))}.#{quote(storage_field(model, field))}"
511
+ operator = (fetch_key(clause, :operator) || "eq").to_s
512
+ value = fetch_key(clause, :value)
513
+ attributes = schema_for(model).fetch(:fields).fetch(field)
514
+ insensitive = insensitive_string_predicate?(clause, attributes)
515
+ predicate_column = insensitive ? "LOWER(#{column})" : column
516
+
517
+ if value.nil? && %w[eq ne].include?(operator)
518
+ null_operator = (operator == "ne") ? "IS NOT NULL" : "IS NULL"
519
+ "#{column} #{null_operator}"
520
+ else
521
+ case operator
522
+ when "in", "not_in"
523
+ values = Array(value).map { |entry| insensitive ? entry.to_s.downcase : coerce_where_value(entry, attributes) }
524
+ placeholders = values.map do |entry|
525
+ params << entry
526
+ placeholder(params.length)
527
+ end.join(", ")
528
+ sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
529
+ "#{predicate_column} #{sql_operator} (#{placeholders})"
530
+ when "contains", "starts_with", "ends_with"
531
+ escaped = escape_like(insensitive ? value.to_s.downcase : value)
532
+ pattern = case operator
533
+ when "starts_with" then "#{escaped}%"
534
+ when "ends_with" then "%#{escaped}"
535
+ else "%#{escaped}%"
303
536
  end
537
+ params << pattern
538
+ "#{predicate_column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
539
+ else
540
+ params << (insensitive ? value.to_s.downcase : coerce_where_value(value, attributes))
541
+ "#{predicate_column} #{sql_operator(operator)} #{placeholder(params.length)}"
304
542
  end
305
-
306
- connector = (index.positive? && fetch_key(clause, :connector).to_s.upcase == "OR") ? "OR" : "AND"
307
- index.zero? ? expression : "#{connector} #{expression}"
308
- end.join(" ")
543
+ end
309
544
  end
310
545
 
311
546
  def order_sql(model, sort_by)
@@ -387,13 +622,18 @@ module BetterAuth
387
622
  end
388
623
 
389
624
  def affected_rows(result)
390
- return result.cmd_tuples if result.respond_to?(:cmd_tuples)
391
- return result.affected_rows if result.respond_to?(:affected_rows)
392
- return result.to_i if result.respond_to?(:to_i)
393
- return connection.affected_rows if connection.respond_to?(:affected_rows)
394
- return connection.changes if connection.respond_to?(:changes)
395
-
396
- 0
625
+ value = if result.respond_to?(:cmd_tuples)
626
+ result.cmd_tuples
627
+ elsif result.respond_to?(:affected_rows)
628
+ result.affected_rows
629
+ elsif result.is_a?(Numeric)
630
+ result
631
+ elsif connection.respond_to?(:affected_rows)
632
+ connection.affected_rows
633
+ elsif connection.respond_to?(:changes)
634
+ connection.changes
635
+ end
636
+ ensure_numeric_affected_rows!(value, "SQL mutation")
397
637
  end
398
638
 
399
639
  def normalize_record(model, row, join: nil)
@@ -440,6 +680,8 @@ module BetterAuth
440
680
  if config[:relation] == "one-to-one" || config[:unique] == true
441
681
  grouped[key][join_model] = joined
442
682
  else
683
+ next if grouped[key][join_model].length >= join_limit(config)
684
+
443
685
  grouped[key][join_model] << joined
444
686
  end
445
687
  end
@@ -500,7 +742,10 @@ module BetterAuth
500
742
  def coerce_value(value, attributes)
501
743
  return value if value.nil?
502
744
  return value ? 1 : 0 if dialect == :sqlite && attributes[:type] == "boolean"
503
- return value.iso8601(6) if dialect == :sqlite && attributes[:type] == "date" && value.respond_to?(:iso8601)
745
+ if dialect == :sqlite && attributes[:type] == "date"
746
+ value = Time.parse(value) if value.is_a?(String)
747
+ return value.iso8601(6) if value.respond_to?(:iso8601)
748
+ end
504
749
  return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
505
750
  return JSON.generate(value) if json_like?(attributes) && !value.is_a?(String)
506
751
  return value.encode(Encoding::UTF_8) if attributes[:type] == "string" && value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT
@@ -70,15 +70,7 @@ module BetterAuth
70
70
  def prepare_context_for_call!(request, headers)
71
71
  return unless context.respond_to?(:prepare_for_api_call!)
72
72
 
73
- source = direct_call_source(request, headers)
74
- if context.options.dynamic_base_url? && source.nil? && !dynamic_base_url_fallback?
75
- raise APIError.new(
76
- "INTERNAL_SERVER_ERROR",
77
- message: "Dynamic baseURL could not be resolved for this direct auth.api call. Pass `headers: request.headers` (or `request`) to the call, or add `fallback` to your baseURL config."
78
- )
79
- end
80
-
81
- context.prepare_for_api_call!(source)
73
+ context.prepare_for_api_call!(direct_call_source(request, headers))
82
74
  end
83
75
 
84
76
  def direct_call_source(request, headers)
@@ -88,13 +80,6 @@ module BetterAuth
88
80
  nil
89
81
  end
90
82
 
91
- def dynamic_base_url_fallback?
92
- config = context.options.base_url_config
93
- return false unless config.is_a?(Hash)
94
-
95
- !!(config[:fallback] || config["fallback"])
96
- end
97
-
98
83
  def run_endpoint_with_hooks(endpoint, endpoint_context)
99
84
  before = run_before_hooks(endpoint_context)
100
85
  return normalize_short_circuit(before, endpoint_context) if before
@@ -114,11 +99,7 @@ module BetterAuth
114
99
  endpoint_context.returned = result.response
115
100
  endpoint_context.response_headers = result.headers.dup
116
101
 
117
- after_result = run_after_hooks(endpoint_context)
118
- result.response = after_result.response
119
- result.headers = after_result.headers
120
- result.status = after_result.status if after_result.status
121
- result
102
+ run_after_hooks(endpoint_context)
122
103
  rescue APIError => error
123
104
  Endpoint::Result.new(response: error, status: error.status_code, headers: error.headers)
124
105
  end
@@ -163,9 +144,7 @@ module BetterAuth
163
144
  next unless hook_result
164
145
 
165
146
  normalized = Endpoint::Result.from_value(hook_result, endpoint_context)
166
- result.response = normalized.response
167
- result.status = normalized.status
168
- result.headers = normalized.headers
147
+ result = normalized
169
148
  endpoint_context.returned = result.response
170
149
  endpoint_context.response_headers = result.headers
171
150
  end
@@ -31,7 +31,7 @@ module BetterAuth
31
31
  }.freeze
32
32
 
33
33
  attr_reader :app_name,
34
- :base_url_config,
34
+ :base_url,
35
35
  :base_path,
36
36
  :context_base_url,
37
37
  :secret,
@@ -39,6 +39,7 @@ module BetterAuth
39
39
  :database,
40
40
  :plugins,
41
41
  :trusted_origins,
42
+ :serving_origins,
42
43
  :rate_limit,
43
44
  :session,
44
45
  :account,
@@ -48,6 +49,7 @@ module BetterAuth
48
49
  :email_and_password,
49
50
  :password_hasher,
50
51
  :email_verification,
52
+ :configured_social_provider_ids,
51
53
  :social_providers,
52
54
  :experimental,
53
55
  :secondary_storage,
@@ -60,6 +62,7 @@ module BetterAuth
60
62
  :logger
61
63
 
62
64
  def initialize(options = {})
65
+ @configured_social_provider_ids = extract_configured_social_provider_ids(options)
63
66
  options = symbolize_keys(options)
64
67
  @explicit_options = deep_dup(options)
65
68
 
@@ -88,8 +91,8 @@ module BetterAuth
88
91
  @secret = legacy_secret || (test_environment? ? DEFAULT_SECRET : nil)
89
92
  @secret_config = @secret
90
93
  end
91
- @base_url_config = options[:base_url]
92
94
  @base_url, @context_base_url = normalize_base_url(options[:base_url])
95
+ @serving_origins = normalize_serving_origins(options[:serving_origins])
93
96
  @session = normalize_session(options[:session])
94
97
  @account = normalize_account(options[:account])
95
98
  @user = symbolize_keys(options[:user] || {})
@@ -110,26 +113,17 @@ module BetterAuth
110
113
  end
111
114
  end
112
115
 
113
- def base_url
114
- Thread.current[base_url_runtime_key] || @base_url
115
- end
116
-
117
- def set_runtime_base_url(value)
118
- Thread.current[base_url_runtime_key] = value
119
- end
120
-
121
- def clear_runtime_base_url!
122
- Thread.current[base_url_runtime_key] = nil
116
+ def explicit_trusted_origins
117
+ configured = @explicit_options[:trusted_origins]
118
+ origins = configured.respond_to?(:call) ? [] : Array(configured).compact
119
+ origins.concat(env_trusted_origins)
120
+ origins.map(&:to_s).reject(&:empty?).uniq
123
121
  end
124
122
 
125
123
  def production?
126
124
  production_environment?
127
125
  end
128
126
 
129
- def dynamic_base_url?
130
- URLHelpers.dynamic_config?(base_url_config)
131
- end
132
-
133
127
  def to_h
134
128
  {
135
129
  app_name: app_name,
@@ -140,6 +134,7 @@ module BetterAuth
140
134
  database: database,
141
135
  plugins: plugins,
142
136
  trusted_origins: trusted_origins,
137
+ serving_origins: serving_origins,
143
138
  rate_limit: rate_limit,
144
139
  session: session,
145
140
  account: account,
@@ -164,7 +159,7 @@ module BetterAuth
164
159
  normalized = symbolize_keys(defaults || {})
165
160
  normalized.each do |key, value|
166
161
  next unless respond_to?(key)
167
- next if key == :database_hooks
162
+ next if key == :database_hooks || key == :configured_social_provider_ids
168
163
 
169
164
  if key == :trusted_origins
170
165
  merge_trusted_origins_default(value)
@@ -231,15 +226,12 @@ module BetterAuth
231
226
 
232
227
  def normalize_base_url(value)
233
228
  configured = value || env_base_url
234
- return ["", ""] unless configured && !configured.empty?
235
-
236
- if URLHelpers.dynamic_config?(configured)
237
- validate_dynamic_base_url!(configured)
238
- resolved = URLHelpers.resolve_base_url(configured, base_path)
239
- return ["", ""] unless resolved
229
+ if configured.nil? || configured.to_s.empty?
230
+ raise Error, "base_url is required. Pass a canonical URL to BetterAuth.auth(base_url: ...) or set BETTER_AUTH_URL."
231
+ end
240
232
 
241
- uri = URI.parse(resolved)
242
- return [self.class.origin_for(uri), resolved.sub(%r{/+\z}, "")]
233
+ if configured.is_a?(Hash)
234
+ raise Error, "Dynamic base_url hashes are no longer supported. Configure a canonical base_url and list request-facing origins in serving_origins."
243
235
  end
244
236
 
245
237
  with_path = append_base_path(configured.to_s)
@@ -250,11 +242,6 @@ module BetterAuth
250
242
  raise Error, "Invalid base URL: #{configured}. Please provide a valid base URL."
251
243
  end
252
244
 
253
- def validate_dynamic_base_url!(value)
254
- allowed_hosts = value[:allowed_hosts] || value["allowed_hosts"] || value[:allowedHosts] || value["allowedHosts"]
255
- raise Error, "baseURL.allowedHosts cannot be empty" if allowed_hosts.respond_to?(:empty?) && allowed_hosts.empty?
256
- end
257
-
258
245
  def normalize_base_path(value)
259
246
  return "" if value.nil? || value == "" || value == "/"
260
247
 
@@ -368,7 +355,11 @@ module BetterAuth
368
355
  end
369
356
 
370
357
  def normalize_plugins(value)
371
- Array(value).compact.reject { |plugin| plugin == false }.map { |plugin| Plugin.coerce(plugin) }
358
+ Array(value).compact.reject { |plugin| plugin == false }.map do |plugin|
359
+ coerced = Plugin.coerce(plugin)
360
+ Plugins.ensure_plugin_loaded_for!(coerced)
361
+ coerced
362
+ end
372
363
  end
373
364
 
374
365
  def normalize_social_providers(value)
@@ -377,24 +368,32 @@ module BetterAuth
377
368
  end
378
369
  end
379
370
 
371
+ def extract_configured_social_provider_ids(options)
372
+ return [].freeze unless options.is_a?(Hash)
373
+
374
+ options.each_with_object([]) do |(key, providers), ids|
375
+ next unless normalize_key(key) == :social_providers && providers.is_a?(Hash)
376
+
377
+ ids.concat(providers.keys.map { |provider_id| provider_id.to_s.dup.freeze })
378
+ end.uniq.freeze
379
+ end
380
+
380
381
  def normalize_trusted_origins(value)
381
- origins = []
382
- origins << base_url unless base_url.nil? || base_url.empty?
383
- origins.concat(dynamic_base_url_trusted_origins)
382
+ origins = serving_origins.dup
384
383
  origins.concat(Array(value).compact) unless value.respond_to?(:call)
385
384
  origins.concat(env_trusted_origins)
386
385
  origins.map(&:to_s).reject(&:empty?).uniq
387
386
  end
388
387
 
389
- def dynamic_base_url_trusted_origins
390
- return [] unless URLHelpers.dynamic_config?(base_url_config)
388
+ def normalize_serving_origins(value)
389
+ ([base_url] + Array(value).compact).map do |origin|
390
+ normalized = origin.to_s.sub(%r{/+\z}, "")
391
+ unless normalized.match?(%r{\Ahttps?://[^/?#@]+\z}i)
392
+ raise Error, "Invalid serving origin: #{origin}. serving_origins entries must be full http(s) origins without a path."
393
+ end
391
394
 
392
- protocol = base_url_config[:protocol] || base_url_config["protocol"] || "https"
393
- allowed_hosts = base_url_config[:allowed_hosts] || base_url_config["allowed_hosts"] || base_url_config[:allowedHosts] || base_url_config["allowedHosts"] || []
394
- Array(allowed_hosts).map do |host|
395
- host = host.to_s
396
- host.match?(%r{\Ahttps?://}i) ? host : "#{protocol}://#{host}"
397
- end
395
+ normalized
396
+ end.uniq
398
397
  end
399
398
 
400
399
  def merge_trusted_origins_default(value)
@@ -479,10 +478,6 @@ module BetterAuth
479
478
  end
480
479
  end
481
480
 
482
- def base_url_runtime_key
483
- :"better_auth_configuration_base_url_#{object_id}"
484
- end
485
-
486
481
  def test_environment?
487
482
  ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test" || ENV["APP_ENV"] == "test"
488
483
  end