standard_id 0.16.1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cc415579c94e298230fc9d647869d7c2ea2b422b1130eac38bd05bba262956a
4
- data.tar.gz: 653d1cd02170d5ba6933973635cb50d62729be158b0037812da8b2f832fb0c0e
3
+ metadata.gz: 50cac29c9420971a28c8c4b401349cc0563270a16d519c0abd9052da600c685a
4
+ data.tar.gz: b47c1a99cc5d7d335f9dee1c53d4831cbcc70b3d933bb6130e600797eb4998df
5
5
  SHA512:
6
- metadata.gz: 16fa94e1dde5fa46e92999ef2fb24b5bb683ff5f5ab3e6549e91f8c738e4cdd42d8e872ab0d13705659ec6ab2bb4c4e184f4947c1d95bf5e00afbc3b9dfe0328
7
- data.tar.gz: b730ef767275f8d71aa9b93a8c8fca55a1935c84e1e4e6eaf022ec935d2d152d7b72e99576b5075e3dffba8269b07be66abf43f4fd96e1f7728858d64244f7e2
6
+ metadata.gz: 04e2bf5d6503e312fecdedfdee8f5eb7b1dd5c438c25f351ab222160249bb4de40d3b7348bffc4431d71047e05cb918ea399e67060ad66d946d17dc66d038ce8
7
+ data.tar.gz: 6f38767fc4bd02d3595cf243bf1d326881cf17baea355577416158bf43418a6423f7d3a14c9c2fa721b38c9f1ff4fa75658cd5b0b2f26e48d6fb970cc041c3cb
@@ -18,8 +18,8 @@ module StandardId
18
18
  # runtime.
19
19
  #
20
20
  # Scope: only validates top-level StandardId.config fields plus the hash
21
- # entries under `oauth.claim_resolvers`. Does NOT modify the StandardConfig
22
- # DSL (the gem's schema doesn't need to know about this).
21
+ # entries under `oauth.claim_resolvers`. Does NOT modify the schema
22
+ # (the gem's schema doesn't need to know about this).
23
23
  module CallableValidator
24
24
  # Each entry: field_path => { signature: "(a, b, c)", arity: Integer, kind: :positional | :keyword, keywords: [..] }
25
25
  # Field path is the method chain on StandardId.config, e.g. "after_sign_in"
@@ -1,9 +1,9 @@
1
1
  # Schema definitions for StandardId
2
2
  # This file defines the configuration schema structure
3
3
 
4
- require "standard_config"
4
+ require "standard_id/config_schema"
5
5
 
6
- StandardConfig.schema.draw do
6
+ StandardId::ConfigSchema.define do
7
7
  scope :base do
8
8
  field :account_class_name, type: :string, default: "User"
9
9
  field :cache_store, type: :any, default: nil
@@ -147,7 +147,7 @@ StandardConfig.schema.draw do
147
147
  #
148
148
  # Note: the scope is named `reset_password` rather than `password_reset` to
149
149
  # avoid a name collision with the `web.password_reset` boolean feature flag
150
- # — StandardConfig resolves unique field names globally.
150
+ # — StandardId::ConfigSchema resolves unique field names globally.
151
151
  field :delivery, type: :symbol, default: :custom
152
152
  field :mailer_from, type: :string, default: "noreply@example.com"
153
153
  field :mailer_subject, type: :string, default: "Reset your password"
@@ -0,0 +1,211 @@
1
+ require "active_support/ordered_options"
2
+ require "concurrent/map"
3
+
4
+ module StandardId
5
+ # Lightweight configuration schema backed by ActiveSupport::OrderedOptions.
6
+ # Replaces the vendored `StandardConfig` DSL/manager. Fields are declared
7
+ # per scope; the resulting top-level config exposes each scope as a nested
8
+ # OrderedOptions and routes any field whose name is unique across scopes
9
+ # to the owning scope (so host apps can read base-scope fields like
10
+ # `config.account_class_name` without the `base.` prefix).
11
+ class ConfigSchema
12
+ Field = Struct.new(:name, :type, :default) do
13
+ def default_value
14
+ return default.call if default.respond_to?(:call)
15
+ return default.dup if default.is_a?(Array) || default.is_a?(Hash)
16
+ default
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def instance = (@instance ||= new)
22
+ def define(&block) = instance.define(&block)
23
+ def add_field(**kwargs) = instance.add_field(**kwargs)
24
+ def build = instance.apply(Config.new)
25
+ end
26
+
27
+ def initialize = @scopes = Concurrent::Map.new
28
+ def scopes = @scopes
29
+ def scope?(name) = @scopes.key?(name.to_sym)
30
+ def field?(scope_name, field_name) = !!@scopes[scope_name.to_sym]&.key?(field_name.to_sym)
31
+ def field_for(scope_name, field_name) = @scopes[scope_name.to_sym]&.[](field_name.to_sym)
32
+
33
+ def define(&block)
34
+ DSL.new(self).instance_eval(&block) if block
35
+ self
36
+ end
37
+
38
+ def add_field(scope:, name:, type: :string, default: nil)
39
+ fields = ensure_scope(scope)
40
+ fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default) }
41
+ end
42
+
43
+ # Register a scope without adding a field. Allows `define { scope :foo }` so
44
+ # provider gems can later `add_field(scope: :foo, ...)` against an existing scope.
45
+ def ensure_scope(name)
46
+ @scopes.compute_if_absent(name.to_sym) { Concurrent::Map.new }
47
+ end
48
+
49
+ # Scopes that declare a field with the given name (used for top-level routing).
50
+ def scopes_with_field(field_name)
51
+ sym = field_name.to_sym
52
+ @scopes.each_pair.with_object([]) { |(s, fs), acc| acc << s if fs.key?(sym) }
53
+ end
54
+
55
+ # Populate the given Config with scope sub-options + defaults. Re-apply is safe;
56
+ # values already set in a Scope are preserved (so provider gems can register
57
+ # fields after host apps have set base values).
58
+ def apply(config)
59
+ config.__schema__ = self
60
+ @scopes.each_pair do |scope_name, fields|
61
+ opts = (config.key?(scope_name) && config[scope_name].is_a?(Scope)) ? config[scope_name] : Scope.new(self, scope_name)
62
+ fields.each_value { |f| opts.write_default(f.name, f.default_value) }
63
+ config.write_raw(scope_name, opts)
64
+ end
65
+ config
66
+ end
67
+
68
+ def cast(value, type)
69
+ return value if value.nil?
70
+ case type
71
+ when :any then value
72
+ when :symbol then value.is_a?(Symbol) ? value : value.to_sym
73
+ when :string then value.to_s
74
+ when :integer then value.to_i
75
+ when :float then value.to_f
76
+ when :array then Array(value)
77
+ when :hash then value.is_a?(Hash) ? value : {}
78
+ when :boolean
79
+ case value
80
+ when true, false then value
81
+ when "true", "1", 1 then true
82
+ when "false", "0", 0 then false
83
+ else !!value
84
+ end
85
+ else value
86
+ end
87
+ end
88
+
89
+ # DSL: `define { scope :base do field :foo, type: :string, default: "x" end }`.
90
+ # Anonymous-class form keeps both levels in one place; #scope yields a sub-DSL
91
+ # that closes over the parent schema + scope name.
92
+ class DSL
93
+ def initialize(schema, scope_name = nil)
94
+ @schema = schema
95
+ @scope_name = scope_name
96
+ end
97
+
98
+ def scope(name, &block)
99
+ @schema.ensure_scope(name)
100
+ DSL.new(@schema, name.to_sym).instance_eval(&block) if block
101
+ end
102
+
103
+ def field(name, type: :string, default: nil, **)
104
+ @schema.add_field(scope: @scope_name, name: name, type: type, default: default)
105
+ end
106
+ end
107
+
108
+ # Per-scope OrderedOptions. Validates writes, casts and dups Array/Hash values
109
+ # on read. When `resolver` is set (via `Config#register`), reads delegate to
110
+ # the resolver-returned hash for dynamic / multi-tenant configuration.
111
+ class Scope < ActiveSupport::OrderedOptions
112
+ RAW_SET = ActiveSupport::OrderedOptions.instance_method(:[]=)
113
+ private_constant :RAW_SET
114
+
115
+ attr_accessor :resolver
116
+
117
+ def initialize(schema, scope_name)
118
+ super()
119
+ @schema = schema
120
+ @scope_name = scope_name
121
+ end
122
+
123
+ def []=(key, value)
124
+ validate!(key)
125
+ super(key.to_sym, value)
126
+ end
127
+
128
+ def [](key)
129
+ sym = key.to_sym
130
+ validate!(sym) unless key?(sym) || resolver
131
+ raw = if resolver
132
+ hash = resolver.call || {}
133
+ if hash.respond_to?(:key?) && hash.respond_to?(:[])
134
+ hash.key?(sym) ? hash[sym] : hash[sym.to_s]
135
+ end
136
+ elsif key?(sym)
137
+ super(sym)
138
+ else
139
+ @schema.field_for(@scope_name, sym)&.default_value
140
+ end
141
+ cast_read(sym, raw)
142
+ end
143
+
144
+ def write_default(key, value)
145
+ return if key?(key.to_sym)
146
+ RAW_SET.bind_call(self, key.to_sym, value)
147
+ end
148
+
149
+ private
150
+
151
+ def validate!(key)
152
+ return if @schema.field?(@scope_name, key)
153
+ raise StandardId::ConfigurationError,
154
+ "Unknown field '#{key}' for scope '#{@scope_name}'. Valid fields: #{@schema.scopes[@scope_name]&.keys}"
155
+ end
156
+
157
+ def cast_read(key, value)
158
+ field = @schema.field_for(@scope_name, key)
159
+ return value unless field
160
+ casted = @schema.cast(value, field.type)
161
+ casted.is_a?(Array) || casted.is_a?(Hash) ? casted.dup : casted
162
+ end
163
+ end
164
+
165
+ # Top-level config: routes unqualified field reads/writes to the owning scope
166
+ # when the name is unique across scopes.
167
+ class Config < ActiveSupport::OrderedOptions
168
+ RAW_SET = ActiveSupport::OrderedOptions.instance_method(:[]=)
169
+ private_constant :RAW_SET
170
+
171
+ attr_accessor :__schema__
172
+
173
+ def register(scope_name, resolver)
174
+ sym = scope_name.to_sym
175
+ unless __schema__&.scope?(sym)
176
+ raise ArgumentError, "Unknown configuration scope: #{sym}. Valid scopes: #{__schema__&.scopes&.keys}"
177
+ end
178
+ self[sym].resolver = resolver
179
+ self
180
+ end
181
+
182
+ def registered?(scope_name) = !!self[scope_name.to_sym]&.resolver
183
+
184
+ def [](key)
185
+ sym = key.to_sym
186
+ return super if key?(sym) || __schema__.nil? || __schema__.scope?(sym)
187
+ target = unique_scope_for(sym)
188
+ target ? self[target][sym] : super
189
+ end
190
+
191
+ def []=(key, value)
192
+ sym = key.to_sym
193
+ if __schema__ && !__schema__.scope?(sym) && !key?(sym) && (target = unique_scope_for(sym))
194
+ self[target][sym] = value
195
+ else
196
+ super(sym, value)
197
+ end
198
+ end
199
+
200
+ # Bypass routing — used by the schema applier.
201
+ def write_raw(key, value) = RAW_SET.bind_call(self, key.to_sym, value)
202
+
203
+ private
204
+
205
+ def unique_scope_for(name)
206
+ matches = __schema__.scopes_with_field(name)
207
+ matches.size == 1 ? matches.first : nil
208
+ end
209
+ end
210
+ end
211
+ end
@@ -59,8 +59,18 @@ module StandardId
59
59
  # We warn (not raise) to avoid breaking apps that intentionally short-
60
60
  # circuit boot (e.g., `assets:precompile` rake tasks with no secrets
61
61
  # available). A hard failure would be hostile to those workflows.
62
+ #
63
+ # IMPORTANT: we must NOT call `app.secret_key_base` directly. Rails 8.1's
64
+ # getter runs the "generate + persist" path on first read, which in turn
65
+ # invokes the setter — and the setter raises when the resolved value is
66
+ # blank (which is exactly the case this check is meant to warn about).
67
+ # So calling the getter here would turn a soft warning into a hard boot
68
+ # failure under parallel workers (e.g. parallel_rspec's `parallel:create`)
69
+ # that don't share a generated key between processes. Instead we read
70
+ # the same underlying sources Rails resolves from (ENV, then credentials)
71
+ # without triggering the write path.
62
72
  def self.verify_host_cookie_encryption!(app)
63
- secret = app.respond_to?(:secret_key_base) ? app.secret_key_base : nil
73
+ secret = host_secret_key_base(app)
64
74
 
65
75
  if secret.blank?
66
76
  Rails.logger.warn(
@@ -73,6 +83,27 @@ module StandardId
73
83
  end
74
84
  end
75
85
 
86
+ # Probe secret_key_base sources without touching the memoizing setter.
87
+ # Mirrors Rails' own resolution order (ENV -> credentials), stopping on
88
+ # the first present value. Returns nil when neither is set — the caller
89
+ # logs a warning in that case.
90
+ def self.host_secret_key_base(app)
91
+ return ENV["SECRET_KEY_BASE"] if ENV["SECRET_KEY_BASE"].present?
92
+
93
+ credentials = app.respond_to?(:credentials) ? app.credentials : nil
94
+ return nil unless credentials&.respond_to?(:secret_key_base)
95
+
96
+ credentials.secret_key_base
97
+ rescue StandardError
98
+ # A broken credentials file or missing master key raises during read.
99
+ # That's the host app's problem to surface elsewhere — here we just
100
+ # want the cookie-encryption warning to fire, so we treat "could not
101
+ # probe" as "not configured" and let Rails error loudly on its own
102
+ # path if the app genuinely needs the secret.
103
+ nil
104
+ end
105
+ private_class_method :host_secret_key_base
106
+
76
107
  # Logs a production-only warning when no global audience allow-list is
77
108
  # configured. With `allowed_audiences` empty, the API token manager
78
109
  # skips decode-time aud enforcement, leaving cross-audience JWT replay
@@ -5,6 +5,12 @@ module StandardId
5
5
  subscribe_to StandardId::Events::PASSWORDLESS_CODE_GENERATED
6
6
 
7
7
  def call(event)
8
+ # Per-call manual-delivery request takes precedence over the global
9
+ # delivery config. Honors Otp.issue(delivery: :manual) callers who
10
+ # deliver the code themselves (custom widget flows, step-up
11
+ # challenges, etc.) and would otherwise receive a duplicate email
12
+ # from this subscriber when c.passwordless.delivery == :built_in.
13
+ return if event[:skip_sender]
8
14
  return unless built_in_delivery?
9
15
  return unless event[:channel] == "email"
10
16
 
@@ -30,7 +30,12 @@ module StandardId
30
30
  expires_in: expires_in,
31
31
  metadata: metadata
32
32
  )
33
- emit_code_generated(challenge, username)
33
+ # skip_sender is forwarded into the event payload so subscribers that
34
+ # deliver on PASSWORDLESS_CODE_GENERATED (e.g. PasswordlessDeliverySubscriber)
35
+ # can honor a per-call manual-delivery request — not just the legacy
36
+ # sender_callback. Without this, Otp.issue(delivery: :manual) silently
37
+ # double-delivers when c.passwordless.delivery == :built_in.
38
+ emit_code_generated(challenge, username, skip_sender: skip_sender)
34
39
  sender_callback&.call(username, challenge.code) unless skip_sender
35
40
  emit_code_sent(username) unless skip_sender
36
41
  challenge
@@ -158,14 +163,15 @@ module StandardId
158
163
  )
159
164
  end
160
165
 
161
- def emit_code_generated(challenge, username)
166
+ def emit_code_generated(challenge, username, skip_sender: false)
162
167
  StandardId::Events.publish(
163
168
  StandardId::Events::PASSWORDLESS_CODE_GENERATED,
164
169
  code_challenge: challenge,
165
170
  identifier: username,
166
171
  channel: connection_type,
167
172
  realm: @realm,
168
- expires_at: challenge.expires_at
173
+ expires_at: challenge.expires_at,
174
+ skip_sender: skip_sender
169
175
  )
170
176
  end
171
177
 
@@ -34,7 +34,6 @@ module StandardId
34
34
  )
35
35
  end
36
36
 
37
-
38
37
  # Get all registered providers
39
38
  # @return [Hash] Provider name => class mapping
40
39
  def all
@@ -50,16 +49,15 @@ module StandardId
50
49
 
51
50
  private
52
51
 
53
- # Register provider's config schema fields with StandardConfig
52
+ # Register provider's config schema fields with the StandardId schema.
53
+ # Thread-safe and idempotent — adding the same field twice is a no-op.
54
54
  # @param provider_class [Class] Provider implementation class
55
55
  def register_config_schema(provider_class)
56
56
  schema = provider_class.config_schema
57
57
  return if schema.nil? || schema.empty?
58
58
 
59
- StandardConfig.schema.scope(:social) do
60
- schema.each do |field_name, options|
61
- field field_name, **options
62
- end
59
+ schema.each do |field_name, options|
60
+ StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **options)
63
61
  end
64
62
  end
65
63
 
@@ -106,7 +106,7 @@ module StandardId
106
106
 
107
107
  # Define configuration schema fields for this provider.
108
108
  #
109
- # Returns a hash of field definitions compatible with StandardConfig schema DSL.
109
+ # Returns a hash of field definitions compatible with the StandardId::ConfigSchema DSL.
110
110
  # These fields will be registered under the :social configuration scope.
111
111
  #
112
112
  # @return [Hash] Field definitions with types and defaults
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.16.1"
2
+ VERSION = "0.17.1"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -3,6 +3,7 @@ require "standard_id/current_attributes"
3
3
  require "standard_id/engine"
4
4
  require "standard_id/web_engine"
5
5
  require "standard_id/api_engine"
6
+ require "standard_id/config_schema"
6
7
  require "standard_id/config/schema"
7
8
  require "standard_id/scope_config"
8
9
  require "standard_id/errors"
@@ -59,20 +60,23 @@ require "standard_id/providers/base"
59
60
  require "standard_id/provider_registry"
60
61
 
61
62
  module StandardId
63
+ CONFIG = Concurrent::Delay.new { ConfigSchema.build }
64
+
62
65
  class << self
63
66
  CACHE_STORE = Concurrent::Delay.new { config.cache_store || Rails.cache }
64
67
  LOGGER = Concurrent::Delay.new { config.logger || Rails.logger }
65
68
 
66
69
  def configure(&block)
67
- StandardConfig.configure(&block)
70
+ yield config if block_given?
71
+ config
68
72
  end
69
73
 
70
74
  def register(scope_name, resolver_proc)
71
- StandardConfig.config.register(scope_name, resolver_proc)
75
+ config.register(scope_name, resolver_proc)
72
76
  end
73
77
 
74
78
  def config
75
- StandardConfig.config
79
+ CONFIG.value
76
80
  end
77
81
 
78
82
  def cache_store
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.1
4
+ version: 0.17.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -13,14 +13,14 @@ dependencies:
13
13
  name: rails
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '8.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '8.0'
26
26
  - !ruby/object:Gem::Dependency
@@ -215,11 +215,6 @@ files:
215
215
  - db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb
216
216
  - lib/generators/standard_id/install/install_generator.rb
217
217
  - lib/generators/standard_id/install/templates/standard_id.rb
218
- - lib/standard_config.rb
219
- - lib/standard_config/config.rb
220
- - lib/standard_config/config_provider.rb
221
- - lib/standard_config/manager.rb
222
- - lib/standard_config/schema.rb
223
218
  - lib/standard_id.rb
224
219
  - lib/standard_id/account_locking.rb
225
220
  - lib/standard_id/account_status.rb
@@ -232,6 +227,7 @@ files:
232
227
  - lib/standard_id/config/callable_validator.rb
233
228
  - lib/standard_id/config/schema.rb
234
229
  - lib/standard_id/config/scope_claims_validator.rb
230
+ - lib/standard_id/config_schema.rb
235
231
  - lib/standard_id/current_attributes.rb
236
232
  - lib/standard_id/engine.rb
237
233
  - lib/standard_id/errors.rb
@@ -305,7 +301,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
305
301
  requirements:
306
302
  - - ">="
307
303
  - !ruby/object:Gem::Version
308
- version: '3.2'
304
+ version: '4.0'
309
305
  required_rubygems_version: !ruby/object:Gem::Requirement
310
306
  requirements:
311
307
  - - ">="
@@ -1,73 +0,0 @@
1
- module StandardConfig
2
- # Manages configuration for the StandardId engine
3
- #
4
- # Usage:
5
- # StandardId.configure do |config|
6
- # config.account_class_name = "User"
7
- # config.cache_store = ActiveSupport::Cache::MemoryStore.new
8
- # config.logger = Rails.logger
9
- # config.allowed_post_logout_redirect_uris = ["https://example.com/logout"]
10
- # end
11
- class Config
12
- # The name of the Account model class as a String, e.g. "User" or "Account"
13
- attr_accessor :account_class_name
14
-
15
- # Optional cache store and logger, used by StandardId.cache_store and StandardId.logger
16
- attr_accessor :cache_store, :logger
17
-
18
- # OAuth issuer identifier for ID tokens
19
- attr_accessor :issuer
20
-
21
- # Optional login URL for redirecting unauthenticated browser requests
22
- # Example: "/login" or a full URL like "https://app.example.com/login"
23
- # If set, Authorization endpoints can redirect to this path with a redirect_uri param
24
- attr_accessor :login_url
25
-
26
- # Social login hooks
27
- attr_accessor :social_account_attributes
28
-
29
- # Passwordless authentication delivery callbacks (deprecated - use events instead)
30
- attr_accessor :passwordless_email_sender, :passwordless_sms_sender
31
-
32
- # Allowed post-logout redirect URIs for OIDC logout endpoint
33
- # If empty or nil, no redirects are allowed and the endpoint will return a JSON message
34
- # If provided, the post_logout_redirect_uri must exactly match one of the values in this list
35
- attr_accessor :allowed_post_logout_redirect_uris
36
-
37
- # Layout name to use for StandardId Web controllers.
38
- # If nil, controllers should default to "application" (host app or dummy app).
39
- # Examples: "application", "standard_id/web/application", "my_custom_layout"
40
- attr_accessor :web_layout
41
-
42
- # Enable Inertia.js rendering for StandardId Web controllers
43
- # When true and inertia_rails gem is available, controllers will render Inertia components
44
- attr_accessor :use_inertia
45
-
46
- # Namespace prefix for Inertia component paths
47
- # Example: "Auth" will generate component paths like "Auth/Login/show"
48
- attr_accessor :inertia_component_namespace
49
-
50
- def initialize
51
- @account_class_name = nil
52
- @cache_store = nil
53
- @logger = nil
54
- @issuer = nil
55
- @login_url = nil
56
- @apple_key_id = nil
57
- @apple_team_id = nil
58
- @social_account_attributes = nil
59
- @passwordless_email_sender = nil
60
- @passwordless_sms_sender = nil
61
- @allowed_post_logout_redirect_uris = []
62
- @web_layout = nil
63
- @use_inertia = nil
64
- @inertia_component_namespace = nil
65
- end
66
-
67
- def account_class
68
- account_class_name.constantize
69
- rescue NameError
70
- raise NameError, "Could not find account class: #{account_class_name}. Please set a valid class name using `StandardId.configure { |c| c.account_class_name = 'YourAccountClass' }`"
71
- end
72
- end
73
- end
@@ -1,82 +0,0 @@
1
- require "ostruct"
2
-
3
- module StandardConfig
4
- class ConfigProvider
5
- def initialize(scope_name, resolver_proc, schema = nil)
6
- @scope_name = scope_name
7
- @resolver_proc = resolver_proc
8
- @schema = schema
9
- end
10
-
11
- def method_missing(method_name, *args)
12
- if method_name.to_s.end_with?("=")
13
- # Setter - only works for static configs (OpenStruct objects)
14
- field_name = method_name.to_s.chomp("=").to_sym
15
- validate_field!(field_name)
16
-
17
- config_object = @resolver_proc.call
18
- if config_object.respond_to?(method_name)
19
- config_object.send(method_name, args.first)
20
- elsif config_object.respond_to?(:[]=)
21
- # Support hash-like providers
22
- value = args.first
23
- config_object[field_name] = value
24
- # Also set string key for convenience if symbol not used by provider
25
- begin
26
- config_object[field_name.to_s] = value
27
- rescue StandardError
28
- # ignore if provider doesn't accept string keys
29
- end
30
- else
31
- raise NoMethodError, "Configuration object doesn't support setting #{field_name}"
32
- end
33
- else
34
- # Getter
35
- get_field(method_name)
36
- end
37
- end
38
-
39
- def get_field(field_name)
40
- validate_field!(field_name)
41
-
42
- config_object = @resolver_proc.call
43
- raw_value = if config_object.respond_to?(field_name)
44
- config_object.send(field_name)
45
- elsif config_object.respond_to?(:[])
46
- config_object[field_name] || config_object[field_name.to_s]
47
- else
48
- nil
49
- end
50
-
51
- # Cast the value according to schema
52
- field_def = @schema&.field_definition(@scope_name, field_name)
53
- return raw_value unless field_def
54
-
55
- casted = @schema&.cast_value(raw_value, field_def.type) || raw_value
56
- # Return dup for mutable structures to prevent accidental mutation of shared defaults
57
- if casted.is_a?(Array)
58
- casted.dup
59
- elsif casted.is_a?(Hash)
60
- casted.dup
61
- else
62
- casted
63
- end
64
- end
65
-
66
- def respond_to_missing?(method_name, include_private = false)
67
- field_name = method_name.to_s.end_with?("=") ? method_name.to_s.chomp("=").to_sym : method_name.to_sym
68
- @schema&.valid_field?(@scope_name, field_name) || super
69
- end
70
-
71
- private
72
-
73
- def validate_field!(field_name)
74
- return unless @schema # Skip validation if no schema provided
75
-
76
- unless @schema.valid_field?(@scope_name, field_name)
77
- valid_fields = @schema.scopes[@scope_name]&.fields&.keys || []
78
- raise ArgumentError, "Unknown field '#{field_name}' for scope '#{@scope_name}'. Valid fields: #{valid_fields}"
79
- end
80
- end
81
- end
82
- end
@@ -1,93 +0,0 @@
1
- require "ostruct"
2
- require "concurrent/map"
3
- require "standard_config/config_provider"
4
-
5
- module StandardConfig
6
- class Manager
7
- def initialize(schema)
8
- @schema = schema
9
- @providers = Concurrent::Map.new
10
- @static_configs = Concurrent::Map.new
11
- end
12
-
13
- # Register a configuration provider for a scope
14
- def register(scope_name, resolver_proc)
15
- scope_name = scope_name.to_sym
16
-
17
- # Validate scope exists in schema
18
- unless @schema.valid_scope?(scope_name)
19
- raise ArgumentError, "Unknown configuration scope: #{scope_name}. Valid scopes: #{@schema.scopes.keys}"
20
- end
21
-
22
- @providers[scope_name] = ConfigProvider.new(scope_name, resolver_proc, @schema)
23
- self
24
- end
25
-
26
- def registered?(scope_name)
27
- @providers.key?(scope_name.to_sym)
28
- end
29
-
30
- # Access configuration scopes via method calls
31
- def method_missing(method_name, *args)
32
- method_str = method_name.to_s
33
- scope_name = method_str.end_with?("=") ? method_str.chomp("=").to_sym : method_name.to_sym
34
-
35
- # Handle field setter via unique scope resolution
36
- if method_str.end_with?("=")
37
- field = scope_name
38
- scopes = @schema.scopes_with_field(field)
39
- if scopes.size == 1
40
- s = scopes.first
41
- provider = @providers.compute_if_absent(s) do
42
- ConfigProvider.new(s, -> { create_static_config_for_scope(s) }, @schema)
43
- end
44
- provider.public_send(method_name, *args)
45
- return args.first
46
- end
47
- end
48
-
49
- # Handle field getter via unique scope resolution
50
- scopes = @schema.scopes_with_field(scope_name)
51
- if scopes.size == 1
52
- s = scopes.first
53
- provider = @providers.compute_if_absent(s) do
54
- ConfigProvider.new(s, -> { create_static_config_for_scope(s) }, @schema)
55
- end
56
- return provider.get_field(scope_name)
57
- end
58
-
59
- # Handle scope access
60
- provider = @providers[scope_name]
61
- return provider if provider
62
-
63
- # Create static provider for valid scopes on first access
64
- if @schema.valid_scope?(scope_name)
65
- return @providers.compute_if_absent(scope_name) do
66
- ConfigProvider.new(scope_name, -> { create_static_config_for_scope(scope_name) }, @schema)
67
- end
68
- end
69
-
70
- super
71
- end
72
-
73
- def respond_to_missing?(method_name, include_private = false)
74
- method_str = method_name.to_s
75
- scope_name = method_str.end_with?("=") ? method_str.chomp("=").to_sym : method_name.to_sym
76
- @schema.valid_scope?(scope_name) ||
77
- @schema.scopes_with_field(scope_name).any? ||
78
- super
79
- end
80
-
81
- private
82
-
83
- def create_static_config_for_scope(scope_name)
84
- @static_configs.compute_if_absent(scope_name) do
85
- OpenStruct.new.tap do |config|
86
- @schema.scopes[scope_name].fields.each do |field_name, field_def|
87
- config.send("#{field_name}=", field_def.default_value)
88
- end
89
- end
90
- end
91
- end
92
- end
93
- end
@@ -1,139 +0,0 @@
1
- require "concurrent/map"
2
-
3
- module StandardConfig
4
- class Schema
5
- def initialize
6
- @scopes = Concurrent::Map.new
7
- end
8
-
9
- # DSL entry
10
- def draw(&block)
11
- Drawer.new(self).instance_eval(&block) if block_given?
12
- self
13
- end
14
-
15
- def scopes
16
- @scopes
17
- end
18
-
19
- def scope(name, &block)
20
- name_sym = name.to_sym
21
- builder = scopes.compute_if_absent(name_sym) { ScopeBuilder.new(name_sym) }
22
- builder.instance_eval(&block) if block_given?
23
- builder
24
- end
25
-
26
- def valid_scope?(name)
27
- scopes.key?(name.to_sym)
28
- end
29
-
30
- def valid_field?(scope_name, field_name)
31
- return false unless valid_scope?(scope_name)
32
- scopes[scope_name.to_sym].fields.key?(field_name.to_sym)
33
- end
34
-
35
- def field_definition(scope_name, field_name)
36
- return nil unless valid_scope?(scope_name)
37
- scopes[scope_name.to_sym].fields[field_name.to_sym]
38
- end
39
-
40
- # Return an array of scope names that define the given field
41
- def scopes_with_field(field_name)
42
- scopes.keys.select { |s| scopes[s].fields.key?(field_name.to_sym) }
43
- end
44
-
45
- def cast_value(value, type)
46
- return value if value.nil?
47
-
48
- case type
49
- when :any
50
- value
51
- when :string
52
- value.to_s
53
- when :integer
54
- value.to_i
55
- when :float
56
- value.to_f
57
- when :boolean
58
- case value
59
- when true, false then value
60
- when "true", "1", 1 then true
61
- when "false", "0", 0 then false
62
- else !!value
63
- end
64
- when :array
65
- Array(value)
66
- when :hash
67
- value.is_a?(Hash) ? value : {}
68
- else
69
- value
70
- end
71
- end
72
-
73
- class ScopeBuilder
74
- attr_reader :name, :fields
75
-
76
- def initialize(name)
77
- @name = name.to_sym
78
- @fields = Concurrent::Map.new
79
- end
80
-
81
- def field(name, type: :string, default: nil, readonly: false)
82
- key = name.to_sym
83
- if @fields.key?(key)
84
- Kernel.warn("[StandardId::Configuration] Redefining field '#{key}' in scope '#{@name}'. Last definition wins.")
85
- end
86
- @fields[key] = FieldDefinition.new(name, type: type, default: default, readonly: readonly)
87
- end
88
- end
89
-
90
- class FieldDefinition
91
- attr_reader :name, :type, :default, :readonly
92
-
93
- def initialize(name, type: :string, default: nil, readonly: false)
94
- @name = name.to_sym
95
- @type = type
96
- @default = default
97
- @readonly = readonly
98
- end
99
-
100
- def default_value
101
- if @default.respond_to?(:call)
102
- @default.call
103
- elsif @default.is_a?(Array)
104
- @default.dup
105
- else
106
- @default
107
- end
108
- end
109
- end
110
-
111
- # Internal DSL driver
112
- class Drawer
113
- def initialize(schema)
114
- @schema = schema
115
- end
116
-
117
- # scope :base do ... end OR scope :passwordless do ... end
118
- def scope(name, &block)
119
- name_sym = name.to_sym
120
- # Ensure scope exists, then evaluate the block in a scoped context
121
- @schema.scope(name_sym)
122
- ScopedScope.new(@schema, name_sym).instance_eval(&block) if block_given?
123
- end
124
- end
125
-
126
- class ScopedScope
127
- def initialize(schema, scope_name)
128
- @schema = schema
129
- @scope_name = scope_name
130
- end
131
-
132
- def field(name, type: :string, default: nil, readonly: false)
133
- # Add field to the last declared scope by using ScopeBuilder within @schema.scope
134
- # This method will be called inside Schema.scope block via Drawer
135
- @schema.scopes[@scope_name].field(name, type: type, default: default, readonly: readonly)
136
- end
137
- end
138
- end
139
- end
@@ -1,45 +0,0 @@
1
- require "standard_config/config"
2
- require "standard_config/config_provider"
3
- require "standard_config/manager"
4
- require "standard_config/schema"
5
-
6
- require "concurrent/delay"
7
-
8
- module StandardConfig
9
- SCHEMA = Concurrent::Delay.new { Schema.new }
10
- MANAGER = Concurrent::Delay.new { Manager.new(SCHEMA.value) }
11
-
12
- class << self
13
- def schema
14
- SCHEMA.value
15
- end
16
-
17
- def configure(&block)
18
- if block_given? && block.arity.zero? && !config.registered?(:base)
19
- config.register(:base, block)
20
- end
21
-
22
- yield config if block_given?
23
-
24
- config
25
- end
26
-
27
- def config
28
- MANAGER.value
29
- end
30
-
31
- private
32
-
33
- def create_default_config
34
- require "ostruct"
35
- static_config = OpenStruct.new
36
- base_scope = schema.scopes[:base]
37
- if base_scope
38
- base_scope.fields.each do |field_name, field_def|
39
- static_config.send("#{field_name}=", field_def.default_value)
40
- end
41
- end
42
- static_config
43
- end
44
- end
45
- end