better_auth-telemetry 0.8.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.
@@ -0,0 +1,662 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Telemetry
5
+ module Detectors
6
+ # AuthConfig detector / redactor. Produces the redacted
7
+ # `payload.config` hash emitted by the init event, mirroring
8
+ # upstream `getTelemetryAuthConfig`.
9
+ #
10
+ # The whole {call} entry point is wrapped in `rescue
11
+ # StandardError; nil` so any failure during redaction degrades
12
+ # the entire `config` payload to `nil` rather than escaping out
13
+ # of the init payload composition in {BetterAuth::Telemetry.create}.
14
+ module AuthConfig
15
+ # Top-level keys emitted in the redacted config payload, in
16
+ # the order produced by upstream `getTelemetryAuthConfig`.
17
+ TOP_LEVEL_KEYS = %i[
18
+ database
19
+ adapter
20
+ emailVerification
21
+ emailAndPassword
22
+ socialProviders
23
+ plugins
24
+ user
25
+ verification
26
+ session
27
+ account
28
+ hooks
29
+ secondaryStorage
30
+ advanced
31
+ trustedOrigins
32
+ rateLimit
33
+ onAPIError
34
+ logger
35
+ databaseHooks
36
+ ].freeze
37
+
38
+ # Models covered by the `databaseHooks` redaction map. The
39
+ # order is fixed to mirror the upstream shape produced by
40
+ # `getTelemetryAuthConfig` so the wire-format key order is
41
+ # stable across runs.
42
+ DATABASE_HOOK_MODELS = %i[user session account verification].freeze
43
+
44
+ # Database operations covered for each model.
45
+ DATABASE_HOOK_OPERATIONS = %i[create update].freeze
46
+
47
+ # Phases covered for each (model, operation) pair. The
48
+ # order is `after` then `before` to match upstream.
49
+ DATABASE_HOOK_PHASES = %i[after before].freeze
50
+
51
+ module_function
52
+
53
+ # ------------------------------------------------------------------
54
+ # Public entry point
55
+ # ------------------------------------------------------------------
56
+
57
+ # Build the redacted `payload.config` hash for the init event.
58
+ #
59
+ # @param options [BetterAuth::Configuration, Hash, nil] the
60
+ # options passed to {BetterAuth::Telemetry.create}. May be
61
+ # a {BetterAuth::Configuration} (production path), the raw
62
+ # options hash that {BetterAuth::Auth.new} would consume,
63
+ # or `nil`. Both shapes are descended via {fetch_path}, so
64
+ # the same redaction pipeline produces deep-equal payloads
65
+ # for matching inputs (Requirement 13.1).
66
+ # @param context [BetterAuth::Telemetry::NormalizedContext, Hash, nil]
67
+ # the normalized context. Only `:database` and `:adapter`
68
+ # overrides are surfaced into the payload as raw
69
+ # pass-through values (Requirement 13.9). The accessor
70
+ # tolerates either a {NormalizedContext} (production path),
71
+ # a raw hash with snake_case or camelCase / symbol or
72
+ # string keys (test seams), or `nil` (top-level keys
73
+ # collapse to `nil`).
74
+ # @return [Hash{Symbol => Object}, nil] the redacted config
75
+ # hash with the upstream top-level key set, or `nil` if
76
+ # anything in the redaction pipeline raises.
77
+ def call(options, context)
78
+ {
79
+ database: context_value(context, :database),
80
+ adapter: context_value(context, :adapter),
81
+ emailVerification: redact_email_verification(options),
82
+ emailAndPassword: redact_email_and_password(options),
83
+ socialProviders: redact_social_providers(options),
84
+ plugins: redact_plugins(options),
85
+ user: redact_user(options),
86
+ verification: redact_verification(options),
87
+ session: redact_session(options),
88
+ account: redact_account(options),
89
+ hooks: redact_hooks(options),
90
+ secondaryStorage: redact_secondary_storage(options),
91
+ advanced: redact_advanced(options),
92
+ trustedOrigins: redact_trusted_origins(options),
93
+ rateLimit: redact_rate_limit(options),
94
+ onAPIError: redact_on_api_error(options),
95
+ logger: redact_logger(options),
96
+ databaseHooks: redact_database_hooks(options)
97
+ }
98
+ rescue
99
+ nil
100
+ end
101
+
102
+ # ------------------------------------------------------------------
103
+ # Redaction primitives
104
+ # ------------------------------------------------------------------
105
+
106
+ # Boolean redaction: collapse any value into a strict
107
+ # `true`/`false`. Used for callable/secret leaves where the
108
+ # actual value must never reach the wire (Requirement 13.3).
109
+ #
110
+ # @param value [Object]
111
+ # @return [Boolean]
112
+ def bool(value)
113
+ !!value
114
+ end
115
+
116
+ # Pass-through helper: emit the value as-is. Used for raw
117
+ # scalars that are safe to ship verbatim (timeouts, lengths,
118
+ # field maps, …).
119
+ #
120
+ # @param value [Object]
121
+ # @return [Object]
122
+ def raw(value)
123
+ value
124
+ end
125
+
126
+ # Presence-aware boolean redaction. Returns `true` only when
127
+ # the value is non-`nil`, not `false`, and not the empty
128
+ # string. Mirrors upstream's `!!value && value !== ""`
129
+ # idiom for fields like `advanced.cookiePrefix` where a
130
+ # missing/empty value is meaningfully different from a set
131
+ # one.
132
+ #
133
+ # @param value [Object]
134
+ # @return [Boolean]
135
+ def bool_present(value)
136
+ !value.nil? && value != "" && value != false
137
+ end
138
+
139
+ # Length helper. Returns the integer length of any
140
+ # `Array`-coercible input, with `nil` and non-array values
141
+ # treated as the empty list. Used for `trustedOrigins`,
142
+ # which is emitted as an integer count (never the contents).
143
+ #
144
+ # @param array [Array, nil, Object]
145
+ # @return [Integer]
146
+ def count(array)
147
+ Array(array).length
148
+ end
149
+
150
+ # ------------------------------------------------------------------
151
+ # Unified accessor
152
+ # ------------------------------------------------------------------
153
+
154
+ # Read a nested value from either a {BetterAuth::Configuration}
155
+ # instance or a raw options hash, using the same snake_case
156
+ # path. Symbol/string key shapes in nested hashes are both
157
+ # accepted.
158
+ #
159
+ # The path's first segment is treated as the
160
+ # {BetterAuth::Configuration} reader name (snake_case). When
161
+ # the source is a Configuration, the first segment is sent
162
+ # via `public_send`; the remainder of the path is descended
163
+ # into the returned value as if it were a hash. When the
164
+ # source is a Hash, every segment is looked up as a hash
165
+ # key, trying both symbol and string forms at each level.
166
+ #
167
+ # Any failure (a missing reader, a missing key, an
168
+ # intermediate non-hash value) returns `nil` so the redaction
169
+ # map can short-circuit cleanly without rescuing per-leaf.
170
+ #
171
+ # @example Configuration source
172
+ # cfg = BetterAuth::Configuration.new(
173
+ # secret: "0"*40,
174
+ # email_verification: { expires_in: 3600 }
175
+ # )
176
+ # AuthConfig.fetch_path(cfg, [:email_verification, :expires_in])
177
+ # # => 3600
178
+ #
179
+ # @example Raw hash source with mixed symbol/string keys
180
+ # opts = { "email_verification" => { send_verification_email: ->{} } }
181
+ # AuthConfig.fetch_path(opts, [:email_verification, :send_verification_email])
182
+ # # => #<Proc:...>
183
+ #
184
+ # @param opts [BetterAuth::Configuration, Hash, nil]
185
+ # @param path [Array<Symbol>] non-empty snake_case path. Each
186
+ # segment is matched against either a `Configuration`
187
+ # reader (first segment only) or a hash key (subsequent
188
+ # segments).
189
+ # @return [Object, nil] the value at `path`, or `nil` when
190
+ # any segment is missing or the source is `nil`.
191
+ def fetch_path(opts, path)
192
+ return nil if opts.nil?
193
+ return nil if path.nil? || path.empty?
194
+
195
+ head, *tail = path
196
+ current = read_root(opts, head)
197
+ return current if tail.empty?
198
+
199
+ tail.reduce(current) do |value, key|
200
+ break nil unless value.is_a?(Hash)
201
+
202
+ hash_lookup(value, key)
203
+ end
204
+ rescue
205
+ nil
206
+ end
207
+
208
+ # ------------------------------------------------------------------
209
+ # Per-section stubs (filled by 4.8 / 4.9 / 4.10 / 4.11)
210
+ # ------------------------------------------------------------------
211
+
212
+ # Build the redacted `payload.config.emailVerification` hash.
213
+ #
214
+ # Every callable leaf is `bool`-redacted (Requirement 13.3) so
215
+ # the actual proc/lambda/object never reaches the wire. The
216
+ # only raw scalar in this section is `expiresIn`.
217
+ #
218
+ # @param opts [BetterAuth::Configuration, Hash, nil]
219
+ # @return [Hash{Symbol => Object}]
220
+ def redact_email_verification(opts)
221
+ {
222
+ sendVerificationEmail: bool(fetch_path(opts, [:email_verification, :send_verification_email])),
223
+ sendOnSignUp: bool(fetch_path(opts, [:email_verification, :send_on_sign_up])),
224
+ sendOnSignIn: bool(fetch_path(opts, [:email_verification, :send_on_sign_in])),
225
+ autoSignInAfterVerification: bool(fetch_path(opts, [:email_verification, :auto_sign_in_after_verification])),
226
+ expiresIn: raw(fetch_path(opts, [:email_verification, :expires_in])),
227
+ beforeEmailVerification: bool(fetch_path(opts, [:email_verification, :before_email_verification])),
228
+ afterEmailVerification: bool(fetch_path(opts, [:email_verification, :after_email_verification]))
229
+ }
230
+ end
231
+
232
+ # Build the redacted `payload.config.emailAndPassword` hash.
233
+ #
234
+ # All callables (`sendResetPassword`, `onPasswordReset`,
235
+ # `password.hash`, `password.verify`, …) are `bool`-redacted
236
+ # per Requirement 13.4. Numeric configuration scalars
237
+ # (`maxPasswordLength`, `minPasswordLength`,
238
+ # `resetPasswordTokenExpiresIn`) are emitted raw.
239
+ #
240
+ # @param opts [BetterAuth::Configuration, Hash, nil]
241
+ # @return [Hash{Symbol => Object}]
242
+ def redact_email_and_password(opts)
243
+ {
244
+ enabled: bool(fetch_path(opts, [:email_and_password, :enabled])),
245
+ disableSignUp: bool(fetch_path(opts, [:email_and_password, :disable_sign_up])),
246
+ requireEmailVerification: bool(fetch_path(opts, [:email_and_password, :require_email_verification])),
247
+ maxPasswordLength: raw(fetch_path(opts, [:email_and_password, :max_password_length])),
248
+ minPasswordLength: raw(fetch_path(opts, [:email_and_password, :min_password_length])),
249
+ sendResetPassword: bool(fetch_path(opts, [:email_and_password, :send_reset_password])),
250
+ resetPasswordTokenExpiresIn: raw(fetch_path(opts, [:email_and_password, :reset_password_token_expires_in])),
251
+ onPasswordReset: bool(fetch_path(opts, [:email_and_password, :on_password_reset])),
252
+ password: {
253
+ hash: bool(fetch_path(opts, [:email_and_password, :password, :hash])),
254
+ verify: bool(fetch_path(opts, [:email_and_password, :password, :verify]))
255
+ },
256
+ autoSignIn: bool(fetch_path(opts, [:email_and_password, :auto_sign_in])),
257
+ revokeSessionsOnPasswordReset: bool(fetch_path(opts, [:email_and_password, :revoke_sessions_on_password_reset]))
258
+ }
259
+ end
260
+
261
+ # Build the redacted `payload.config.socialProviders` array.
262
+ #
263
+ # The Ruby port stores `social_providers` as a `Hash` keyed
264
+ # by provider id (`:github`, `:google`, …) where each value
265
+ # is the per-provider options hash. Upstream emits an
266
+ # `Array` of redacted-provider hashes, so we walk the source
267
+ # hash and rebuild the wire shape one entry at a time.
268
+ #
269
+ # Mapping of keys (Ruby snake_case → upstream camelCase):
270
+ # bool leaves (callable / presence indicators):
271
+ # map_profile_to_user → mapProfileToUser
272
+ # disable_default_scope → disableDefaultScope
273
+ # disable_id_token_sign_in → disableIdTokenSignIn
274
+ # get_user_info → getUserInfo
275
+ # override_user_info_on_sign_in → overrideUserInfoOnSignIn
276
+ # verify_id_token → verifyIdToken
277
+ # refresh_access_token → refreshAccessToken
278
+ # raw pass-through scalars:
279
+ # disable_implicit_sign_up → disableImplicitSignUp
280
+ # disable_sign_up → disableSignUp
281
+ # prompt → prompt
282
+ # scope → scope
283
+ #
284
+ # @param opts [BetterAuth::Configuration, Hash, nil]
285
+ # @return [Array<Hash{Symbol => Object}>]
286
+ def redact_social_providers(opts)
287
+ providers = fetch_path(opts, [:social_providers])
288
+ return [] unless providers.is_a?(Hash)
289
+
290
+ providers.map do |provider_id, raw_provider|
291
+ provider = raw_provider.is_a?(Hash) ? raw_provider : {}
292
+ {
293
+ id: provider_id.to_s,
294
+ mapProfileToUser: bool(provider[:map_profile_to_user]),
295
+ disableDefaultScope: bool(provider[:disable_default_scope]),
296
+ disableIdTokenSignIn: bool(provider[:disable_id_token_sign_in]),
297
+ disableImplicitSignUp: provider[:disable_implicit_sign_up],
298
+ disableSignUp: provider[:disable_sign_up],
299
+ getUserInfo: bool(provider[:get_user_info]),
300
+ overrideUserInfoOnSignIn: bool(provider[:override_user_info_on_sign_in]),
301
+ prompt: provider[:prompt],
302
+ verifyIdToken: bool(provider[:verify_id_token]),
303
+ scope: provider[:scope],
304
+ refreshAccessToken: bool(provider[:refresh_access_token])
305
+ }
306
+ end
307
+ end
308
+
309
+ # Build the redacted `payload.config.plugins` value.
310
+ #
311
+ # Upstream emits an array of plugin id strings, or `null`
312
+ # (Ruby `nil`) when no plugins are configured. We mirror
313
+ # that exactly: each configured plugin is asked for its
314
+ # `id`, the result is stringified, blanks (nil / empty) are
315
+ # dropped, and the empty-list case collapses to `nil`.
316
+ #
317
+ # @param opts [BetterAuth::Configuration, Hash, nil]
318
+ # @return [Array<String>, nil]
319
+ def redact_plugins(opts)
320
+ plugins = fetch_path(opts, [:plugins])
321
+ ids = Array(plugins).map { |plugin| plugin.respond_to?(:id) ? plugin.id.to_s : nil }
322
+ ids = ids.reject { |id| id.nil? || id.empty? }
323
+ ids.empty? ? nil : ids
324
+ end
325
+
326
+ # Build the redacted `payload.config.user` hash.
327
+ #
328
+ # Every leaf except `changeEmail.sendChangeEmailConfirmation`
329
+ # is a raw pass-through. The send-change-email confirmation
330
+ # callback is `bool`-redacted per Requirement 13.4 so the
331
+ # callable never reaches the wire.
332
+ #
333
+ # @param opts [BetterAuth::Configuration, Hash, nil]
334
+ # @return [Hash{Symbol => Object}]
335
+ def redact_user(opts)
336
+ {
337
+ modelName: raw(fetch_path(opts, [:user, :model_name])),
338
+ fields: raw(fetch_path(opts, [:user, :fields])),
339
+ additionalFields: raw(fetch_path(opts, [:user, :additional_fields])),
340
+ changeEmail: {
341
+ enabled: raw(fetch_path(opts, [:user, :change_email, :enabled])),
342
+ sendChangeEmailConfirmation: bool(fetch_path(opts, [:user, :change_email, :send_change_email_confirmation]))
343
+ }
344
+ }
345
+ end
346
+
347
+ # Build the redacted `payload.config.verification` hash. All
348
+ # leaves are raw pass-throughs (no callables in this section).
349
+ #
350
+ # @param opts [BetterAuth::Configuration, Hash, nil]
351
+ # @return [Hash{Symbol => Object}]
352
+ def redact_verification(opts)
353
+ {
354
+ modelName: raw(fetch_path(opts, [:verification, :model_name])),
355
+ disableCleanup: raw(fetch_path(opts, [:verification, :disable_cleanup])),
356
+ fields: raw(fetch_path(opts, [:verification, :fields]))
357
+ }
358
+ end
359
+
360
+ # Build the redacted `payload.config.session` hash. Every
361
+ # documented leaf is a raw pass-through; nested
362
+ # `cookieCache.*` keys are emitted as their own sub-hash
363
+ # mirroring upstream.
364
+ #
365
+ # @param opts [BetterAuth::Configuration, Hash, nil]
366
+ # @return [Hash{Symbol => Object}]
367
+ def redact_session(opts)
368
+ {
369
+ modelName: raw(fetch_path(opts, [:session, :model_name])),
370
+ additionalFields: raw(fetch_path(opts, [:session, :additional_fields])),
371
+ cookieCache: {
372
+ enabled: raw(fetch_path(opts, [:session, :cookie_cache, :enabled])),
373
+ maxAge: raw(fetch_path(opts, [:session, :cookie_cache, :max_age])),
374
+ strategy: raw(fetch_path(opts, [:session, :cookie_cache, :strategy]))
375
+ },
376
+ disableSessionRefresh: raw(fetch_path(opts, [:session, :disable_session_refresh])),
377
+ expiresIn: raw(fetch_path(opts, [:session, :expires_in])),
378
+ fields: raw(fetch_path(opts, [:session, :fields])),
379
+ freshAge: raw(fetch_path(opts, [:session, :fresh_age])),
380
+ preserveSessionInDatabase: raw(fetch_path(opts, [:session, :preserve_session_in_database])),
381
+ storeSessionInDatabase: raw(fetch_path(opts, [:session, :store_session_in_database])),
382
+ updateAge: raw(fetch_path(opts, [:session, :update_age]))
383
+ }
384
+ end
385
+
386
+ # Build the redacted `payload.config.account` hash. Every
387
+ # documented leaf is a raw pass-through; nested
388
+ # `accountLinking.*` keys are emitted as their own sub-hash
389
+ # mirroring upstream.
390
+ #
391
+ # @param opts [BetterAuth::Configuration, Hash, nil]
392
+ # @return [Hash{Symbol => Object}]
393
+ def redact_account(opts)
394
+ {
395
+ modelName: raw(fetch_path(opts, [:account, :model_name])),
396
+ fields: raw(fetch_path(opts, [:account, :fields])),
397
+ encryptOAuthTokens: raw(fetch_path(opts, [:account, :encrypt_oauth_tokens])),
398
+ updateAccountOnSignIn: raw(fetch_path(opts, [:account, :update_account_on_sign_in])),
399
+ accountLinking: {
400
+ enabled: raw(fetch_path(opts, [:account, :account_linking, :enabled])),
401
+ trustedProviders: raw(fetch_path(opts, [:account, :account_linking, :trusted_providers])),
402
+ updateUserInfoOnLink: raw(fetch_path(opts, [:account, :account_linking, :update_user_info_on_link])),
403
+ allowUnlinkingAll: raw(fetch_path(opts, [:account, :account_linking, :allow_unlinking_all]))
404
+ }
405
+ }
406
+ end
407
+
408
+ # Build the redacted `payload.config.hooks` hash.
409
+ #
410
+ # Both `before` and `after` may be a single proc, an array
411
+ # of procs, or `nil`. The redaction collapses any non-nil/
412
+ # non-false value into `true`, so callable references never
413
+ # leak (Requirement 13.4).
414
+ #
415
+ # @param opts [BetterAuth::Configuration, Hash, nil]
416
+ # @return [Hash{Symbol => Object}]
417
+ def redact_hooks(opts)
418
+ {
419
+ after: bool(fetch_path(opts, [:hooks, :after])),
420
+ before: bool(fetch_path(opts, [:hooks, :before]))
421
+ }
422
+ end
423
+
424
+ # Build the redacted `payload.config.secondaryStorage` value.
425
+ #
426
+ # Upstream emits `!!options.secondaryStorage`: a strict
427
+ # boolean indicating whether a secondary storage backend has
428
+ # been wired up, never the storage object itself
429
+ # (Requirement 13.4 — callable / object references must not
430
+ # reach the wire).
431
+ #
432
+ # @param opts [BetterAuth::Configuration, Hash, nil]
433
+ # @return [Boolean]
434
+ def redact_secondary_storage(opts)
435
+ bool(fetch_path(opts, [:secondary_storage]))
436
+ end
437
+
438
+ # Build the redacted `payload.config.advanced` hash.
439
+ #
440
+ # The shape mirrors upstream `getTelemetryAuthConfig`'s
441
+ # `advanced` block, including the rename from the Ruby
442
+ # source key `default_cookie_attributes` to the upstream
443
+ # wire key `cookieAttributes` (Requirement 13.7).
444
+ #
445
+ # The four boolean-redacted leaves protect host-identifying
446
+ # values from leaking onto the wire (Requirement 13.3 /
447
+ # 13.4):
448
+ #
449
+ # * `cookiePrefix` — the literal cookie name prefix.
450
+ # * `cookies` — the per-cookie configuration hash.
451
+ # * `crossSubDomainCookies.domain` — host-identifying
452
+ # domain string for cross-subdomain cookies.
453
+ # * `cookieAttributes.domain` — host-identifying domain
454
+ # string for the default cookie attributes.
455
+ #
456
+ # Every other leaf is a raw pass-through scalar.
457
+ #
458
+ # @param opts [BetterAuth::Configuration, Hash, nil]
459
+ # @return [Hash{Symbol => Object}]
460
+ def redact_advanced(opts)
461
+ {
462
+ cookiePrefix: bool(fetch_path(opts, [:advanced, :cookie_prefix])),
463
+ cookies: bool(fetch_path(opts, [:advanced, :cookies])),
464
+ crossSubDomainCookies: {
465
+ domain: bool(fetch_path(opts, [:advanced, :cross_sub_domain_cookies, :domain])),
466
+ enabled: raw(fetch_path(opts, [:advanced, :cross_sub_domain_cookies, :enabled])),
467
+ additionalCookies: raw(fetch_path(opts, [:advanced, :cross_sub_domain_cookies, :additional_cookies]))
468
+ },
469
+ database: {
470
+ generateId: raw(fetch_path(opts, [:advanced, :database, :generate_id])),
471
+ defaultFindManyLimit: raw(fetch_path(opts, [:advanced, :database, :default_find_many_limit]))
472
+ },
473
+ useSecureCookies: raw(fetch_path(opts, [:advanced, :use_secure_cookies])),
474
+ ipAddress: {
475
+ disableIpTracking: raw(fetch_path(opts, [:advanced, :ip_address, :disable_ip_tracking])),
476
+ ipAddressHeaders: raw(fetch_path(opts, [:advanced, :ip_address, :ip_address_headers]))
477
+ },
478
+ disableCSRFCheck: raw(fetch_path(opts, [:advanced, :disable_csrf_check])),
479
+ cookieAttributes: {
480
+ expires: raw(fetch_path(opts, [:advanced, :default_cookie_attributes, :expires])),
481
+ secure: raw(fetch_path(opts, [:advanced, :default_cookie_attributes, :secure])),
482
+ sameSite: raw(fetch_path(opts, [:advanced, :default_cookie_attributes, :same_site])),
483
+ domain: bool(fetch_path(opts, [:advanced, :default_cookie_attributes, :domain])),
484
+ path: raw(fetch_path(opts, [:advanced, :default_cookie_attributes, :path])),
485
+ httpOnly: raw(fetch_path(opts, [:advanced, :default_cookie_attributes, :http_only]))
486
+ }
487
+ }
488
+ end
489
+
490
+ # Build the redacted `payload.config.trustedOrigins` value.
491
+ #
492
+ # Upstream emits `options.trustedOrigins?.length`: an integer
493
+ # count of configured origins, or `nil` when the key is
494
+ # absent. We never emit the origin strings themselves, since
495
+ # they identify customer hosts (Requirement 13.7).
496
+ #
497
+ # The Ruby `Configuration#trusted_origins` reader normalizes
498
+ # the input into an array (folding in `base_url`,
499
+ # dynamic-base-url hosts, and the
500
+ # `BETTER_AUTH_TRUSTED_ORIGINS` env list); the count we emit
501
+ # matches whatever that normalization produced. When the
502
+ # source is a raw hash, we count the literal value at
503
+ # `:trusted_origins`.
504
+ #
505
+ # @param opts [BetterAuth::Configuration, Hash, nil]
506
+ # @return [Integer, nil]
507
+ def redact_trusted_origins(opts)
508
+ value = fetch_path(opts, [:trusted_origins])
509
+ return nil if value.nil?
510
+
511
+ count(value)
512
+ end
513
+
514
+ # Build the redacted `payload.config.rateLimit` hash.
515
+ #
516
+ # `customStorage` is callable in the upstream type and is
517
+ # therefore boolean-redacted (Requirement 13.4); every other
518
+ # leaf is a raw pass-through scalar.
519
+ #
520
+ # @param opts [BetterAuth::Configuration, Hash, nil]
521
+ # @return [Hash{Symbol => Object}]
522
+ def redact_rate_limit(opts)
523
+ {
524
+ storage: raw(fetch_path(opts, [:rate_limit, :storage])),
525
+ modelName: raw(fetch_path(opts, [:rate_limit, :model_name])),
526
+ window: raw(fetch_path(opts, [:rate_limit, :window])),
527
+ customStorage: bool(fetch_path(opts, [:rate_limit, :custom_storage])),
528
+ enabled: raw(fetch_path(opts, [:rate_limit, :enabled])),
529
+ max: raw(fetch_path(opts, [:rate_limit, :max]))
530
+ }
531
+ end
532
+
533
+ # Build the redacted `payload.config.onAPIError` hash.
534
+ #
535
+ # `onError` is callable in the upstream type and is
536
+ # therefore boolean-redacted (Requirement 13.4). `errorURL`
537
+ # and `throw` are raw pass-through scalars.
538
+ #
539
+ # @param opts [BetterAuth::Configuration, Hash, nil]
540
+ # @return [Hash{Symbol => Object}]
541
+ def redact_on_api_error(opts)
542
+ {
543
+ errorURL: raw(fetch_path(opts, [:on_api_error, :error_url])),
544
+ onError: bool(fetch_path(opts, [:on_api_error, :on_error])),
545
+ throw: raw(fetch_path(opts, [:on_api_error, :throw]))
546
+ }
547
+ end
548
+
549
+ # Build the redacted `payload.config.logger` hash.
550
+ #
551
+ # `log` is callable in the upstream type and is therefore
552
+ # boolean-redacted (Requirement 13.4). `disabled` and
553
+ # `level` are raw pass-through scalars.
554
+ #
555
+ # @param opts [BetterAuth::Configuration, Hash, nil]
556
+ # @return [Hash{Symbol => Object}]
557
+ def redact_logger(opts)
558
+ {
559
+ disabled: raw(fetch_path(opts, [:logger, :disabled])),
560
+ level: raw(fetch_path(opts, [:logger, :level])),
561
+ log: bool(fetch_path(opts, [:logger, :log]))
562
+ }
563
+ end
564
+
565
+ # Build the redacted `payload.config.databaseHooks` tree.
566
+ #
567
+ # The full upstream shape is a 4 × 2 × 2 nested tree:
568
+ #
569
+ # { user, session, account, verification } ×
570
+ # { create, update } ×
571
+ # { before, after }
572
+ #
573
+ # giving sixteen leaves total. Every leaf is a callable in
574
+ # the upstream type, so every leaf is boolean-redacted
575
+ # (Requirement 13.8). The full tree is always emitted with
576
+ # the same shape so downstream consumers can rely on the
577
+ # key set being stable; missing leaves collapse to `false`.
578
+ #
579
+ # @param opts [BetterAuth::Configuration, Hash, nil]
580
+ # @return [Hash{Symbol => Hash}]
581
+ def redact_database_hooks(opts)
582
+ DATABASE_HOOK_MODELS.each_with_object({}) do |model, result|
583
+ result[model] = DATABASE_HOOK_OPERATIONS.each_with_object({}) do |operation, ops|
584
+ ops[operation] = DATABASE_HOOK_PHASES.each_with_object({}) do |phase, phases|
585
+ phases[phase] = bool(fetch_path(opts, [:database_hooks, model, operation, phase]))
586
+ end
587
+ end
588
+ end
589
+ end
590
+
591
+ # ------------------------------------------------------------------
592
+ # Internal helpers
593
+ # ------------------------------------------------------------------
594
+
595
+ # Read a single override key from the {NormalizedContext}
596
+ # surface, accepting either a {NormalizedContext} instance
597
+ # (the production path), a raw hash with snake_case or
598
+ # camelCase keys in symbol or string form (test seams), or
599
+ # `nil`. Returns the raw value when present, `nil`
600
+ # otherwise. Used to inject `payload[:database]` and
601
+ # `payload[:adapter]` from the call-site context override
602
+ # without going through the redaction map (Requirement
603
+ # 13.9 — context overrides are pass-through).
604
+ #
605
+ # @param context [Object, nil]
606
+ # @param key [Symbol] one of `:database` or `:adapter`.
607
+ # @return [Object, nil]
608
+ def context_value(context, key)
609
+ return nil if context.nil?
610
+ return context.public_send(key) if context.respond_to?(key)
611
+ return nil unless context.is_a?(Hash)
612
+
613
+ symbol_key = key.is_a?(Symbol) ? key : key.to_s.to_sym
614
+ return context[symbol_key] if context.key?(symbol_key)
615
+
616
+ string_key = key.to_s
617
+ return context[string_key] if context.key?(string_key)
618
+
619
+ nil
620
+ rescue
621
+ nil
622
+ end
623
+
624
+ # Read the root (first segment) of a `fetch_path` lookup.
625
+ # For a {BetterAuth::Configuration} we call the snake_case
626
+ # reader; for a Hash we look up the key under both symbol
627
+ # and string forms; for any other object we return `nil`.
628
+ #
629
+ # @param opts [BetterAuth::Configuration, Hash, Object]
630
+ # @param key [Symbol]
631
+ # @return [Object, nil]
632
+ def read_root(opts, key)
633
+ if defined?(::BetterAuth::Configuration) && opts.is_a?(::BetterAuth::Configuration)
634
+ return opts.public_send(key) if opts.respond_to?(key)
635
+
636
+ return nil
637
+ end
638
+
639
+ return hash_lookup(opts, key) if opts.is_a?(Hash)
640
+
641
+ nil
642
+ end
643
+
644
+ # Look up a key in a hash trying both symbol and string
645
+ # forms. Returns `nil` when neither shape contains the key.
646
+ #
647
+ # @param hash [Hash]
648
+ # @param key [Symbol, String]
649
+ # @return [Object, nil]
650
+ def hash_lookup(hash, key)
651
+ symbol_key = key.is_a?(Symbol) ? key : key.to_s.to_sym
652
+ return hash[symbol_key] if hash.key?(symbol_key)
653
+
654
+ string_key = key.to_s
655
+ return hash[string_key] if hash.key?(string_key)
656
+
657
+ nil
658
+ end
659
+ end
660
+ end
661
+ end
662
+ end