better_auth 0.3.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +24 -0
- data/lib/better_auth/adapters/internal_adapter.rb +10 -7
- data/lib/better_auth/adapters/memory.rb +57 -11
- data/lib/better_auth/adapters/sql.rb +123 -20
- data/lib/better_auth/api.rb +114 -9
- data/lib/better_auth/async.rb +70 -0
- data/lib/better_auth/configuration.rb +97 -7
- data/lib/better_auth/context.rb +165 -12
- data/lib/better_auth/cookies.rb +6 -4
- data/lib/better_auth/core.rb +2 -0
- data/lib/better_auth/crypto/jwe.rb +27 -5
- data/lib/better_auth/crypto.rb +32 -0
- data/lib/better_auth/database_hooks.rb +8 -8
- data/lib/better_auth/deprecate.rb +28 -0
- data/lib/better_auth/endpoint.rb +92 -5
- data/lib/better_auth/error.rb +8 -1
- data/lib/better_auth/host.rb +166 -0
- data/lib/better_auth/instrumentation.rb +74 -0
- data/lib/better_auth/logger.rb +31 -0
- data/lib/better_auth/middleware/origin_check.rb +2 -2
- data/lib/better_auth/oauth2.rb +94 -0
- data/lib/better_auth/plugins/admin/schema.rb +2 -2
- data/lib/better_auth/plugins/admin.rb +344 -16
- data/lib/better_auth/plugins/anonymous.rb +37 -3
- data/lib/better_auth/plugins/device_authorization.rb +102 -5
- data/lib/better_auth/plugins/dub.rb +148 -0
- data/lib/better_auth/plugins/email_otp.rb +261 -19
- data/lib/better_auth/plugins/expo.rb +17 -1
- data/lib/better_auth/plugins/generic_oauth.rb +67 -35
- data/lib/better_auth/plugins/jwt.rb +37 -4
- data/lib/better_auth/plugins/last_login_method.rb +2 -2
- data/lib/better_auth/plugins/magic_link.rb +66 -3
- data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
- data/lib/better_auth/plugins/mcp/config.rb +51 -0
- data/lib/better_auth/plugins/mcp/consent.rb +31 -0
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
- data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
- data/lib/better_auth/plugins/mcp/registration.rb +31 -0
- data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
- data/lib/better_auth/plugins/mcp/schema.rb +91 -0
- data/lib/better_auth/plugins/mcp/token.rb +108 -0
- data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
- data/lib/better_auth/plugins/mcp.rb +111 -263
- data/lib/better_auth/plugins/multi_session.rb +61 -3
- data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
- data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
- data/lib/better_auth/plugins/oidc_provider.rb +118 -14
- data/lib/better_auth/plugins/one_tap.rb +7 -2
- data/lib/better_auth/plugins/one_time_token.rb +42 -2
- data/lib/better_auth/plugins/open_api.rb +163 -318
- data/lib/better_auth/plugins/organization/schema.rb +6 -0
- data/lib/better_auth/plugins/organization.rb +186 -56
- data/lib/better_auth/plugins/phone_number.rb +141 -6
- data/lib/better_auth/plugins/siwe.rb +69 -3
- data/lib/better_auth/plugins/two_factor.rb +118 -41
- data/lib/better_auth/plugins/username.rb +57 -2
- data/lib/better_auth/rate_limiter.rb +38 -0
- data/lib/better_auth/request_state.rb +44 -0
- data/lib/better_auth/response.rb +42 -0
- data/lib/better_auth/router.rb +7 -1
- data/lib/better_auth/routes/account.rb +220 -42
- data/lib/better_auth/routes/email_verification.rb +98 -14
- data/lib/better_auth/routes/password.rb +126 -8
- data/lib/better_auth/routes/session.rb +128 -13
- data/lib/better_auth/routes/sign_in.rb +26 -2
- data/lib/better_auth/routes/sign_out.rb +13 -1
- data/lib/better_auth/routes/sign_up.rb +70 -4
- data/lib/better_auth/routes/social.rb +132 -7
- data/lib/better_auth/routes/user.rb +228 -20
- data/lib/better_auth/routes/validation.rb +50 -0
- data/lib/better_auth/secret_config.rb +115 -0
- data/lib/better_auth/session.rb +13 -2
- data/lib/better_auth/url_helpers.rb +206 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +12 -0
- metadata +23 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd7630e5e1e1775f9db5545b1f81cbe0fed67da4e38ac59e0aa92ec3a896da3a
|
|
4
|
+
data.tar.gz: 24bfbe49981f12d00533f0db6acc9e899bdeb3edea1145f2e6c38d5cc9e8493d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e168ce722c56f26dddc5e0e4eb34626ae6b4523aae547768970bde07b68f56ba31db302a2e1ea8abf0e4d1e5a5ad98018a7f0129174cc3ef09486294c7bfa28a
|
|
7
|
+
data.tar.gz: b7b9e1bbbab49acc51d876dadeebf067976658782af41a1b82e7c78d3fc2b2d15106d0e90ac7699a380e8b7f4b9211ad105bb388d8f95db17f8624b537e29d7b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
- Modernized the MCP plugin to use OAuth Provider-style client, token, metadata, and protected-resource behavior while keeping legacy MCP routes as aliases.
|
|
11
|
+
|
|
12
|
+
## [0.4.0] - 2026-04-30
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added upstream-parity helpers for async execution, host resolution, instrumentation, request state, URL handling, OAuth2, deprecation warnings, and expanded route behavior.
|
|
17
|
+
- Added two-factor, OAuth protocol, social route, organization, admin, adapter, schema, and session parity coverage.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Aligned core auth, email OTP, generic OAuth, organization, two-factor, OAuth protocol, adapter, router, rate-limiter, logger, and middleware behavior more closely with upstream Better Auth.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fixed upstream parity gaps in organization handling, generic OAuth user info, email OTP sign-up, database schema behavior, and route/session edge cases.
|
|
26
|
+
|
|
10
27
|
## [0.3.0] - 2026-04-29
|
|
11
28
|
|
|
12
29
|
### Added
|
data/README.md
CHANGED
|
@@ -66,6 +66,30 @@ auth = BetterAuth.auth(
|
|
|
66
66
|
)
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
### Secret Rotation
|
|
70
|
+
|
|
71
|
+
Better Auth Ruby supports upstream-style non-destructive rotation for encrypted data through versioned secrets. The first entry is used for new encrypted payloads; older entries remain available for decrypting existing data.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
auth = BetterAuth.auth(
|
|
75
|
+
secret: ENV["BETTER_AUTH_SECRET"], # legacy fallback for older encrypted data
|
|
76
|
+
secrets: [
|
|
77
|
+
{ version: 2, value: ENV.fetch("BETTER_AUTH_SECRET_V2") },
|
|
78
|
+
{ version: 1, value: ENV.fetch("BETTER_AUTH_SECRET_V1") }
|
|
79
|
+
],
|
|
80
|
+
database: :memory
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
You can also configure the same list via `BETTER_AUTH_SECRETS`:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
BETTER_AUTH_SECRETS="2:new-secret-base64,1:old-secret-base64"
|
|
88
|
+
BETTER_AUTH_SECRET="legacy-secret-for-pre-rotation-data"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Signed cookies and HMAC/JWT signatures continue to use the current `secret`, matching upstream Better Auth behavior.
|
|
92
|
+
|
|
69
93
|
### Password Hashing
|
|
70
94
|
|
|
71
95
|
Better Auth Ruby uses upstream-compatible `scrypt` password hashes by default through Ruby's `OpenSSL::KDF.scrypt`, so no extra password-hashing gem is required for the default setup.
|
|
@@ -15,18 +15,18 @@ module BetterAuth
|
|
|
15
15
|
@hooks = DatabaseHooks.new(adapter, options)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def create_oauth_user(user, account)
|
|
18
|
+
def create_oauth_user(user, account, context: nil)
|
|
19
19
|
adapter.transaction do
|
|
20
|
-
created_user = create_user(user)
|
|
20
|
+
created_user = create_user(user, context: context)
|
|
21
21
|
created_account = create_account(stringify_keys(account).merge("userId" => created_user["id"]))
|
|
22
22
|
{user: created_user, account: created_account}
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def create_user(user)
|
|
27
|
-
data = timestamps.merge(stringify_keys(user))
|
|
26
|
+
def create_user(user = nil, context: nil, **keywords)
|
|
27
|
+
data = timestamps.merge(stringify_keys((user || {}).merge(keywords)))
|
|
28
28
|
data["email"] = data["email"].to_s.downcase if data["email"]
|
|
29
|
-
hooks.create(data, "user")
|
|
29
|
+
hooks.create(data, "user", context: context)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def create_account(account)
|
|
@@ -59,9 +59,12 @@ module BetterAuth
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def delete_user(user_id)
|
|
62
|
-
|
|
62
|
+
deleted = hooks.delete([{field: "id", value: user_id}], "user")
|
|
63
|
+
return false if deleted == false
|
|
64
|
+
|
|
63
65
|
hooks.delete_many([{field: "userId", value: user_id}], "account")
|
|
64
|
-
|
|
66
|
+
delete_sessions(user_id) if !secondary_storage || options.session[:store_session_in_database]
|
|
67
|
+
deleted
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
def create_session(user_id, dont_remember_me = false, override = nil, override_all = false, context = nil)
|
|
@@ -156,33 +156,79 @@ module BetterAuth
|
|
|
156
156
|
value = fetch_key(clause, :value)
|
|
157
157
|
operator = (fetch_key(clause, :operator) || "eq").to_s
|
|
158
158
|
current = record[field]
|
|
159
|
+
comparable = coerce_where_value(record, field, value, operator)
|
|
159
160
|
|
|
160
161
|
case operator
|
|
161
162
|
when "in"
|
|
162
|
-
Array(
|
|
163
|
+
Array(comparable).include?(current)
|
|
163
164
|
when "not_in"
|
|
164
|
-
!Array(
|
|
165
|
+
!Array(comparable).include?(current)
|
|
165
166
|
when "contains"
|
|
166
|
-
current.to_s.include?(
|
|
167
|
+
current.to_s.include?(comparable.to_s)
|
|
167
168
|
when "starts_with"
|
|
168
|
-
current.to_s.start_with?(
|
|
169
|
+
current.to_s.start_with?(comparable.to_s)
|
|
169
170
|
when "ends_with"
|
|
170
|
-
current.to_s.end_with?(
|
|
171
|
+
current.to_s.end_with?(comparable.to_s)
|
|
171
172
|
when "ne"
|
|
172
|
-
current !=
|
|
173
|
+
current != comparable
|
|
173
174
|
when "gt"
|
|
174
|
-
!
|
|
175
|
+
!comparable.nil? && current > comparable
|
|
175
176
|
when "gte"
|
|
176
|
-
!
|
|
177
|
+
!comparable.nil? && current >= comparable
|
|
177
178
|
when "lt"
|
|
178
|
-
!
|
|
179
|
+
!comparable.nil? && current < comparable
|
|
179
180
|
when "lte"
|
|
180
|
-
!
|
|
181
|
+
!comparable.nil? && current <= comparable
|
|
181
182
|
else
|
|
182
|
-
current ==
|
|
183
|
+
current == comparable
|
|
183
184
|
end
|
|
184
185
|
end
|
|
185
186
|
|
|
187
|
+
def coerce_where_value(record, field, value, operator)
|
|
188
|
+
attributes = schema_for_record_field(record, field)
|
|
189
|
+
return value unless attributes
|
|
190
|
+
return Array(value).map { |entry| coerce_scalar_where_value(entry, attributes) } if %w[in not_in].include?(operator)
|
|
191
|
+
|
|
192
|
+
coerce_scalar_where_value(value, attributes)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def schema_for_record_field(record, field)
|
|
196
|
+
db.each_key do |model|
|
|
197
|
+
fields = Schema.auth_tables(options)[model]&.fetch(:fields, nil)
|
|
198
|
+
next unless fields&.key?(field)
|
|
199
|
+
return fields[field] if table_for(model).include?(record)
|
|
200
|
+
end
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def coerce_scalar_where_value(value, attributes)
|
|
205
|
+
return value if value.nil?
|
|
206
|
+
|
|
207
|
+
case attributes[:type]
|
|
208
|
+
when "boolean"
|
|
209
|
+
return false if value == false || value == 0 || value.to_s.downcase == "false" || value.to_s == "0"
|
|
210
|
+
return true if value == true || value == 1 || value.to_s.downcase == "true" || value.to_s == "1"
|
|
211
|
+
when "number"
|
|
212
|
+
return coerce_number(value)
|
|
213
|
+
when "date"
|
|
214
|
+
return Time.parse(value) if value.is_a?(String)
|
|
215
|
+
when "number[]"
|
|
216
|
+
return Array(value).map { |entry| coerce_number(entry) }
|
|
217
|
+
when "string[]"
|
|
218
|
+
return Array(value).map(&:to_s)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
value
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def coerce_number(value)
|
|
225
|
+
return value unless value.is_a?(String)
|
|
226
|
+
return value.to_i if /\A-?\d+\z/.match?(value)
|
|
227
|
+
return value.to_f if /\A-?\d+\.\d+\z/.match?(value)
|
|
228
|
+
|
|
229
|
+
value
|
|
230
|
+
end
|
|
231
|
+
|
|
186
232
|
def sort_records(model, records, sort_by)
|
|
187
233
|
field = Schema.storage_key(fetch_key(sort_by, :field))
|
|
188
234
|
direction = fetch_key(sort_by, :direction).to_s
|
|
@@ -173,8 +173,7 @@ module BetterAuth
|
|
|
173
173
|
end
|
|
174
174
|
|
|
175
175
|
def join_select_sql(model, join)
|
|
176
|
-
join.flat_map do |join_model,
|
|
177
|
-
join_model = join_model.to_s
|
|
176
|
+
normalized_join(model, join).flat_map do |join_model, _config|
|
|
178
177
|
schema_for(join_model).fetch(:fields).map do |field, attributes|
|
|
179
178
|
column = attributes[:field_name] || physical_name(field)
|
|
180
179
|
"#{quote(join_model)}.#{quote(column)} AS #{quote("#{join_model}__#{column}")}"
|
|
@@ -185,17 +184,79 @@ module BetterAuth
|
|
|
185
184
|
def join_sql(model, join)
|
|
186
185
|
return "" unless join
|
|
187
186
|
|
|
188
|
-
join.map do |join_model,
|
|
187
|
+
normalized_join(model, join).map do |join_model, config|
|
|
188
|
+
local_field = storage_field(model, config.fetch(:from))
|
|
189
|
+
foreign_field = storage_field(join_model, config.fetch(:to))
|
|
190
|
+
" LEFT JOIN #{quote(table_for(join_model))} AS #{quote(join_model)} ON #{quote(join_model)}.#{quote(foreign_field)} = #{quote(table_for(model))}.#{quote(local_field)}"
|
|
191
|
+
end.join
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def normalized_join(model, join)
|
|
195
|
+
return {} unless join
|
|
196
|
+
|
|
197
|
+
join.each_with_object({}) do |(join_model, config), result|
|
|
189
198
|
join_model = join_model.to_s
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
result[join_model] = normalize_join_config(model.to_s, join_model, config)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def normalize_join_config(model, join_model, config)
|
|
204
|
+
if config.is_a?(Hash) && (config.key?(:on) || config.key?("on"))
|
|
205
|
+
on = config[:on] || config["on"]
|
|
206
|
+
from = storage_key(fetch_key(on, :from))
|
|
207
|
+
to = storage_key(fetch_key(on, :to))
|
|
208
|
+
relation = config[:relation] || config["relation"]
|
|
209
|
+
limit = config[:limit] || config["limit"]
|
|
210
|
+
return {from: from, to: to, relation: relation, limit: limit, unique: unique_join_field?(join_model, to)}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
inferred = inferred_join_config(model, join_model)
|
|
214
|
+
if config.is_a?(Hash)
|
|
215
|
+
relation = config[:relation] || config["relation"]
|
|
216
|
+
limit = config[:limit] || config["limit"]
|
|
217
|
+
inferred = inferred.merge(relation: relation) if relation
|
|
218
|
+
inferred = inferred.merge(limit: limit) if limit
|
|
219
|
+
end
|
|
220
|
+
inferred
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def inferred_join_config(model, join_model)
|
|
224
|
+
foreign_keys = schema_for(join_model).fetch(:fields).select do |_field, attributes|
|
|
225
|
+
reference_model_matches?(attributes, model)
|
|
226
|
+
end
|
|
227
|
+
forward_join = true
|
|
228
|
+
|
|
229
|
+
if foreign_keys.empty?
|
|
230
|
+
foreign_keys = schema_for(model).fetch(:fields).select do |_field, attributes|
|
|
231
|
+
reference_model_matches?(attributes, join_model)
|
|
197
232
|
end
|
|
198
|
-
|
|
233
|
+
forward_join = false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
raise Error, "No foreign key found for model #{join_model} and base model #{model} while performing join operation." if foreign_keys.empty?
|
|
237
|
+
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
|
|
238
|
+
|
|
239
|
+
foreign_key, attributes = foreign_keys.first
|
|
240
|
+
reference = attributes.fetch(:references)
|
|
241
|
+
if forward_join
|
|
242
|
+
unique = attributes[:unique] == true
|
|
243
|
+
{from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
|
|
244
|
+
else
|
|
245
|
+
{from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def reference_model_matches?(attributes, model)
|
|
250
|
+
reference = attributes[:references]
|
|
251
|
+
return false unless reference
|
|
252
|
+
|
|
253
|
+
reference_model = reference[:model] || reference["model"]
|
|
254
|
+
reference_model.to_s == model.to_s || reference_model.to_s == table_for(model)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def unique_join_field?(model, field)
|
|
258
|
+
field = storage_key(field)
|
|
259
|
+
field == "id" || schema_for(model).fetch(:fields).dig(field, :unique) == true
|
|
199
260
|
end
|
|
200
261
|
|
|
201
262
|
def build_where(model, where, params)
|
|
@@ -204,10 +265,11 @@ module BetterAuth
|
|
|
204
265
|
column = "#{quote(table_for(model))}.#{quote(storage_field(model, field))}"
|
|
205
266
|
operator = (fetch_key(clause, :operator) || "eq").to_s
|
|
206
267
|
value = fetch_key(clause, :value)
|
|
268
|
+
attributes = schema_for(model).fetch(:fields).fetch(field)
|
|
207
269
|
|
|
208
270
|
expression = case operator
|
|
209
271
|
when "in", "not_in"
|
|
210
|
-
values = Array(value)
|
|
272
|
+
values = Array(value).map { |entry| coerce_where_value(entry, attributes) }
|
|
211
273
|
placeholders = values.map do |entry|
|
|
212
274
|
params << entry
|
|
213
275
|
placeholder(params.length)
|
|
@@ -223,7 +285,7 @@ module BetterAuth
|
|
|
223
285
|
params << pattern
|
|
224
286
|
"#{column} LIKE #{placeholder(params.length)}"
|
|
225
287
|
else
|
|
226
|
-
params << value
|
|
288
|
+
params << coerce_where_value(value, attributes)
|
|
227
289
|
"#{column} #{sql_operator(operator)} #{placeholder(params.length)}"
|
|
228
290
|
end
|
|
229
291
|
|
|
@@ -302,8 +364,7 @@ module BetterAuth
|
|
|
302
364
|
output[field] = coerce_output_value(fetch_row(row, column), attributes) if row_key?(row, column)
|
|
303
365
|
end
|
|
304
366
|
|
|
305
|
-
join
|
|
306
|
-
join_model = join_model.to_s
|
|
367
|
+
normalized_join(model, join).each_key do |join_model|
|
|
307
368
|
record[join_model] = normalize_joined_record(join_model, row)
|
|
308
369
|
end
|
|
309
370
|
|
|
@@ -319,16 +380,34 @@ module BetterAuth
|
|
|
319
380
|
end
|
|
320
381
|
|
|
321
382
|
def collection_join?(model, join)
|
|
322
|
-
model
|
|
383
|
+
normalized_join(model, join).any? do |_join_model, config|
|
|
384
|
+
config[:relation] != "one-to-one" && config[:unique] != true
|
|
385
|
+
end
|
|
323
386
|
end
|
|
324
387
|
|
|
325
|
-
def aggregate_collection_joins(
|
|
388
|
+
def aggregate_collection_joins(model, records, join)
|
|
389
|
+
join_config = normalized_join(model, join)
|
|
326
390
|
grouped = {}
|
|
327
391
|
records.each do |record|
|
|
328
392
|
key = record.fetch("id")
|
|
329
|
-
grouped[key] ||=
|
|
330
|
-
|
|
331
|
-
|
|
393
|
+
grouped[key] ||= begin
|
|
394
|
+
base = record.reject { |field, _value| join_config.key?(field) }
|
|
395
|
+
join_defaults = join_config.each_with_object({}) do |(join_model, config), defaults|
|
|
396
|
+
defaults[join_model] = (config[:relation] == "one-to-one" || config[:unique] == true) ? nil : []
|
|
397
|
+
end
|
|
398
|
+
base.merge(join_defaults)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
join_config.each do |join_model, config|
|
|
402
|
+
joined = record[join_model]
|
|
403
|
+
next unless joined&.values&.any?
|
|
404
|
+
|
|
405
|
+
if config[:relation] == "one-to-one" || config[:unique] == true
|
|
406
|
+
grouped[key][join_model] = joined
|
|
407
|
+
else
|
|
408
|
+
grouped[key][join_model] << joined
|
|
409
|
+
end
|
|
410
|
+
end
|
|
332
411
|
end
|
|
333
412
|
grouped.values
|
|
334
413
|
end
|
|
@@ -385,6 +464,22 @@ module BetterAuth
|
|
|
385
464
|
value
|
|
386
465
|
end
|
|
387
466
|
|
|
467
|
+
def coerce_where_value(value, attributes)
|
|
468
|
+
return value if value.nil?
|
|
469
|
+
|
|
470
|
+
case attributes[:type]
|
|
471
|
+
when "boolean"
|
|
472
|
+
return coerce_value(false, attributes) if value == false || value == 0 || value.to_s.downcase == "false" || value.to_s == "0"
|
|
473
|
+
return coerce_value(true, attributes) if value == true || value == 1 || value.to_s.downcase == "true" || value.to_s == "1"
|
|
474
|
+
when "number"
|
|
475
|
+
return coerce_number(value)
|
|
476
|
+
when "date"
|
|
477
|
+
return Time.parse(value) if value.is_a?(String)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
coerce_value(value, attributes)
|
|
481
|
+
end
|
|
482
|
+
|
|
388
483
|
def coerce_output_value(value, attributes)
|
|
389
484
|
return value if value.nil?
|
|
390
485
|
return coerce_boolean(value) if attributes[:type] == "boolean"
|
|
@@ -412,6 +507,14 @@ module BetterAuth
|
|
|
412
507
|
value
|
|
413
508
|
end
|
|
414
509
|
|
|
510
|
+
def coerce_number(value)
|
|
511
|
+
return value unless value.is_a?(String)
|
|
512
|
+
return value.to_i if /\A-?\d+\z/.match?(value)
|
|
513
|
+
return value.to_f if /\A-?\d+\.\d+\z/.match?(value)
|
|
514
|
+
|
|
515
|
+
value
|
|
516
|
+
end
|
|
517
|
+
|
|
415
518
|
def stringify_keys(data)
|
|
416
519
|
data.each_with_object({}) do |(key, value), result|
|
|
417
520
|
result[storage_key(key)] = value
|
data/lib/better_auth/api.rb
CHANGED
|
@@ -14,18 +14,34 @@ module BetterAuth
|
|
|
14
14
|
context.reset_runtime! if context.respond_to?(:reset_runtime!)
|
|
15
15
|
endpoint = endpoints.fetch(key.to_sym)
|
|
16
16
|
input = symbolize_keys(input || {})
|
|
17
|
+
request = input[:request]
|
|
18
|
+
headers = request_headers(request).merge(normalize_headers_hash(input[:headers] || {}))
|
|
19
|
+
begin
|
|
20
|
+
prepare_context_for_call!(request, headers)
|
|
21
|
+
rescue APIError => error
|
|
22
|
+
result = Endpoint::Result.new(response: error, status: error.status_code, headers: error.headers)
|
|
23
|
+
return format_result(result, input)
|
|
24
|
+
rescue Error => error
|
|
25
|
+
api_error = APIError.new("INTERNAL_SERVER_ERROR", message: error.message)
|
|
26
|
+
result = Endpoint::Result.new(response: api_error, status: api_error.status_code, headers: api_error.headers)
|
|
27
|
+
return format_result(result, input)
|
|
28
|
+
end
|
|
29
|
+
input[:as_response] = true if request_like?(request) && !input.key?(:as_response)
|
|
17
30
|
endpoint_context = Endpoint::Context.new(
|
|
18
31
|
path: endpoint.path,
|
|
19
|
-
method: input[:method] || Array(endpoint.methods).first,
|
|
32
|
+
method: input[:method] || request_method(request) || Array(endpoint.methods).first,
|
|
20
33
|
query: input[:query] || {},
|
|
21
34
|
body: input[:body] || {},
|
|
22
35
|
params: input[:params] || {},
|
|
23
|
-
headers:
|
|
24
|
-
context: context
|
|
36
|
+
headers: headers,
|
|
37
|
+
context: context,
|
|
38
|
+
request: request_like?(request) ? request : nil
|
|
25
39
|
)
|
|
26
40
|
|
|
27
41
|
result = run_endpoint_with_hooks(endpoint, endpoint_context)
|
|
28
42
|
format_result(result, input)
|
|
43
|
+
ensure
|
|
44
|
+
context.clear_runtime! if context.respond_to?(:clear_runtime!)
|
|
29
45
|
end
|
|
30
46
|
|
|
31
47
|
def execute(endpoint, endpoint_context)
|
|
@@ -51,6 +67,34 @@ module BetterAuth
|
|
|
51
67
|
.to_sym
|
|
52
68
|
end
|
|
53
69
|
|
|
70
|
+
def prepare_context_for_call!(request, headers)
|
|
71
|
+
return unless context.respond_to?(:prepare_for_api_call!)
|
|
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)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def direct_call_source(request, headers)
|
|
85
|
+
return request if request_like?(request)
|
|
86
|
+
return headers if headers.key?("host") || headers.key?("x-forwarded-host")
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
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
|
+
|
|
54
98
|
def run_endpoint_with_hooks(endpoint, endpoint_context)
|
|
55
99
|
before = run_before_hooks(endpoint_context)
|
|
56
100
|
return normalize_short_circuit(before, endpoint_context) if before
|
|
@@ -136,15 +180,16 @@ module BetterAuth
|
|
|
136
180
|
end
|
|
137
181
|
|
|
138
182
|
def format_result(result, input)
|
|
139
|
-
return result.
|
|
183
|
+
return result.to_response if result.raw_response?
|
|
184
|
+
as_response = input[:as_response] || request_like?(input[:request])
|
|
140
185
|
|
|
141
186
|
if result.response.is_a?(APIError)
|
|
142
|
-
return error_response(result.response, headers: result.headers) if
|
|
187
|
+
return error_response(result.response, headers: result.headers) if as_response
|
|
143
188
|
|
|
144
|
-
raise result.response
|
|
189
|
+
raise error_with_headers(result.response, result.headers)
|
|
145
190
|
end
|
|
146
191
|
|
|
147
|
-
return result.
|
|
192
|
+
return result.to_response if as_response
|
|
148
193
|
|
|
149
194
|
if input[:return_headers]
|
|
150
195
|
output = {
|
|
@@ -165,7 +210,17 @@ module BetterAuth
|
|
|
165
210
|
response: error.to_h,
|
|
166
211
|
status: error.status_code,
|
|
167
212
|
headers: Endpoint::Result.merge_headers(headers, error.headers)
|
|
168
|
-
).
|
|
213
|
+
).to_response
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def error_with_headers(error, headers)
|
|
217
|
+
APIError.new(
|
|
218
|
+
error.status,
|
|
219
|
+
message: error.message,
|
|
220
|
+
headers: Endpoint::Result.merge_headers(headers, error.headers),
|
|
221
|
+
code: error.code,
|
|
222
|
+
body: error.body
|
|
223
|
+
)
|
|
169
224
|
end
|
|
170
225
|
|
|
171
226
|
def before_hooks
|
|
@@ -207,11 +262,61 @@ module BetterAuth
|
|
|
207
262
|
hash[key] || hash[key.to_s]
|
|
208
263
|
end
|
|
209
264
|
|
|
265
|
+
def request_like?(value)
|
|
266
|
+
return false unless value
|
|
267
|
+
|
|
268
|
+
value.respond_to?(:request_method) || request_method(value) || !request_headers(value).empty?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def request_method(request)
|
|
272
|
+
return unless request
|
|
273
|
+
return request.request_method if request.respond_to?(:request_method)
|
|
274
|
+
|
|
275
|
+
request.public_send(:method)
|
|
276
|
+
rescue ArgumentError, NoMethodError
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def request_headers(request)
|
|
281
|
+
return {} unless request
|
|
282
|
+
|
|
283
|
+
if request.respond_to?(:env)
|
|
284
|
+
return headers_from_env(request.env)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
return normalize_headers_hash(request.headers) if request.respond_to?(:headers)
|
|
288
|
+
|
|
289
|
+
{}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def headers_from_env(env)
|
|
293
|
+
env.each_with_object({}) do |(key, value), headers|
|
|
294
|
+
case key
|
|
295
|
+
when "CONTENT_TYPE"
|
|
296
|
+
headers["content-type"] = value if value
|
|
297
|
+
when "CONTENT_LENGTH"
|
|
298
|
+
headers["content-length"] = value if value
|
|
299
|
+
else
|
|
300
|
+
next unless key.start_with?("HTTP_")
|
|
301
|
+
|
|
302
|
+
header = key.delete_prefix("HTTP_").downcase.tr("_", "-")
|
|
303
|
+
headers[header] = value
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def normalize_headers_hash(headers)
|
|
309
|
+
headers.each_with_object({}) do |(key, value), result|
|
|
310
|
+
result[key.to_s.downcase.tr("_", "-")] = value
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
210
314
|
def symbolize_keys(value)
|
|
211
315
|
return value unless value.is_a?(Hash)
|
|
212
316
|
|
|
213
317
|
value.each_with_object({}) do |(key, object_value), result|
|
|
214
|
-
|
|
318
|
+
normalized_key = normalize_key(key)
|
|
319
|
+
result[normalized_key] = object_value
|
|
215
320
|
end
|
|
216
321
|
end
|
|
217
322
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Async
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def map_concurrent(items, concurrency:, &mapper)
|
|
8
|
+
list = items.to_a
|
|
9
|
+
return [] if list.empty?
|
|
10
|
+
|
|
11
|
+
width = normalized_concurrency(concurrency, list.length)
|
|
12
|
+
results = Array.new(list.length)
|
|
13
|
+
next_index = 0
|
|
14
|
+
first_error = nil
|
|
15
|
+
mutex = Mutex.new
|
|
16
|
+
status = Queue.new
|
|
17
|
+
|
|
18
|
+
workers = Array.new(width) do
|
|
19
|
+
Thread.new do
|
|
20
|
+
loop do
|
|
21
|
+
index = mutex.synchronize do
|
|
22
|
+
break if first_error || next_index >= list.length
|
|
23
|
+
|
|
24
|
+
current = next_index
|
|
25
|
+
next_index += 1
|
|
26
|
+
current
|
|
27
|
+
end
|
|
28
|
+
break unless index
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
results[index] = mapper.call(list[index], index)
|
|
32
|
+
rescue => error
|
|
33
|
+
mutex.synchronize { first_error ||= error }
|
|
34
|
+
status << [:error, error]
|
|
35
|
+
break
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
ensure
|
|
39
|
+
status << [:done, nil]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
done = 0
|
|
44
|
+
while done < workers.length
|
|
45
|
+
type, error = status.pop
|
|
46
|
+
if type == :error
|
|
47
|
+
workers.each { |worker| worker.kill if worker.alive? }
|
|
48
|
+
workers.each(&:join)
|
|
49
|
+
raise error
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
done += 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
raise first_error if first_error
|
|
56
|
+
|
|
57
|
+
results
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalized_concurrency(concurrency, item_count)
|
|
61
|
+
raw = begin
|
|
62
|
+
Float(concurrency).floor
|
|
63
|
+
rescue ArgumentError, TypeError
|
|
64
|
+
1
|
|
65
|
+
end
|
|
66
|
+
raw = 1 if raw < 1
|
|
67
|
+
[raw, item_count].min
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|