standard_id 0.16.0 → 0.17.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/app/controllers/concerns/standard_id/set_current_request_details.rb +33 -0
- data/app/controllers/standard_id/api/sessions_controller.rb +7 -3
- data/app/models/standard_id/refresh_token.rb +23 -27
- data/app/models/standard_id/session.rb +8 -1
- data/lib/standard_id/api/authentication_guard.rb +26 -18
- data/lib/standard_id/config/callable_validator.rb +2 -2
- data/lib/standard_id/config/schema.rb +17 -3
- data/lib/standard_id/config_schema.rb +211 -0
- data/lib/standard_id/engine.rb +32 -1
- data/lib/standard_id/provider_registry.rb +4 -6
- data/lib/standard_id/providers/base.rb +1 -1
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +7 -3
- metadata +5 -9
- data/lib/standard_config/config.rb +0 -73
- data/lib/standard_config/config_provider.rb +0 -82
- data/lib/standard_config/manager.rb +0 -93
- data/lib/standard_config/schema.rb +0 -139
- data/lib/standard_config.rb +0 -45
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4eb189901d171ba934268cf6cc834e292b7a007f7124a69c61b073cdc518cf7
|
|
4
|
+
data.tar.gz: 13e8b7939cfc95e67430d7bd02fecf8705cf2f5c436b719d9cb2a807647a042a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8405e9b87e4d67d7632329d5d1a9a0165d1fe5828eeb0e4c82052ee96e61f37edbac90f908f914328acef1a891f245216c9855adef8d1afd9506bd83b809ada2
|
|
7
|
+
data.tar.gz: f5118fc1300f5f83d21f388778cbf25d4edd41a13c83e9a8fd75274f777e9465ad861439cdbbf8ae761ecaff2fbfef255bddfac96eb187e69e834848a8e340ba
|
|
@@ -4,6 +4,7 @@ module StandardId
|
|
|
4
4
|
|
|
5
5
|
included do
|
|
6
6
|
before_action :set_current_request_details
|
|
7
|
+
after_action :clear_rails_event_context
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
private
|
|
@@ -14,6 +15,38 @@ module StandardId
|
|
|
14
15
|
::Current.request_id = request.request_id if ::Current.respond_to?(:request_id=)
|
|
15
16
|
::Current.ip_address = StandardId::Utils::IpNormalizer.normalize(request.remote_ip) if ::Current.respond_to?(:ip_address=)
|
|
16
17
|
::Current.user_agent = request.user_agent if ::Current.respond_to?(:user_agent=)
|
|
18
|
+
|
|
19
|
+
set_rails_event_context
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Mirror request details into the Rails 8.1+ structured event reporter so
|
|
23
|
+
# that `Rails.event.notify` calls made during this request automatically
|
|
24
|
+
# carry request_id / ip_address / user_agent. Feature-detected: on older
|
|
25
|
+
# Rails versions this is a no-op. Reads straight from `::Current` — setters
|
|
26
|
+
# and getters on `ActiveSupport::CurrentAttributes` are paired, so the
|
|
27
|
+
# `respond_to?(:foo=)` checks above also guarantee the getter exists.
|
|
28
|
+
def set_rails_event_context
|
|
29
|
+
return unless defined?(::Current) && rails_event_available?
|
|
30
|
+
|
|
31
|
+
Rails.event.set_context(
|
|
32
|
+
request_id: (::Current.request_id if ::Current.respond_to?(:request_id)),
|
|
33
|
+
ip_address: (::Current.ip_address if ::Current.respond_to?(:ip_address)),
|
|
34
|
+
user_agent: (::Current.user_agent if ::Current.respond_to?(:user_agent))
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Rails 8.1 clears fiber-local state between requests via middleware, but
|
|
39
|
+
# thread-pooled servers (Puma, Falcon) can reuse the same fiber across
|
|
40
|
+
# requests. An explicit clear ensures a denied-upstream value cannot leak
|
|
41
|
+
# into the next request handled by the same worker.
|
|
42
|
+
def clear_rails_event_context
|
|
43
|
+
return unless rails_event_available?
|
|
44
|
+
|
|
45
|
+
Rails.event.clear_context
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rails_event_available?
|
|
49
|
+
Rails.respond_to?(:event) && Rails.event.respond_to?(:set_context)
|
|
17
50
|
end
|
|
18
51
|
end
|
|
19
52
|
end
|
|
@@ -26,15 +26,19 @@ module StandardId
|
|
|
26
26
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
|
+
# All Session subclasses live on the same STI table and therefore
|
|
30
|
+
# always respond to these columns — the prior `respond_to?` guards
|
|
31
|
+
# were defensive overhead that allocated per record. Direct access
|
|
32
|
+
# is both cheaper and clearer.
|
|
29
33
|
def serialize_session(session)
|
|
30
34
|
{
|
|
31
35
|
id: session.id,
|
|
32
36
|
type: session.type&.demodulize,
|
|
33
37
|
created_at: session.created_at.iso8601,
|
|
34
|
-
last_refreshed_at: session.
|
|
35
|
-
ip_address: session.
|
|
38
|
+
last_refreshed_at: session.last_refreshed_at&.iso8601,
|
|
39
|
+
ip_address: session.ip_address,
|
|
36
40
|
# user_agent is the API-facing name for the device_agent model attribute
|
|
37
|
-
user_agent: session.
|
|
41
|
+
user_agent: session.device_agent
|
|
38
42
|
}.compact
|
|
39
43
|
end
|
|
40
44
|
end
|
|
@@ -48,34 +48,30 @@ module StandardId
|
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
51
|
+
# Collect every token in this token's family (all ancestors + all
|
|
52
|
+
# descendants reachable via previous_token_id) in a single recursive
|
|
53
|
+
# CTE. Previously we walked the chain in Ruby with one query per
|
|
54
|
+
# generation — fine for small families, O(depth) under reuse-detection
|
|
55
|
+
# storms. The CTE is one round trip.
|
|
56
|
+
#
|
|
57
|
+
# `UNION` (not `UNION ALL`) deduplicates against the full accumulator
|
|
58
|
+
# at each step — so a row already emitted earlier in the traversal is
|
|
59
|
+
# skipped, preventing infinite loops on cyclic data. Supported by
|
|
60
|
+
# PostgreSQL, SQLite 3.8+, and MySQL 8+.
|
|
54
61
|
def family_tokens
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
current_ids = [root_id]
|
|
69
|
-
|
|
70
|
-
loop do
|
|
71
|
-
next_ids = self.class.where(previous_token_id: current_ids).pluck(:id)
|
|
72
|
-
break if next_ids.empty?
|
|
73
|
-
|
|
74
|
-
ids.concat(next_ids)
|
|
75
|
-
current_ids = next_ids
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
ids
|
|
62
|
+
table = self.class.quoted_table_name
|
|
63
|
+
sql = <<~SQL.squish
|
|
64
|
+
WITH RECURSIVE family AS (
|
|
65
|
+
SELECT id, previous_token_id FROM #{table} WHERE id = :id
|
|
66
|
+
UNION
|
|
67
|
+
SELECT rt.id, rt.previous_token_id
|
|
68
|
+
FROM #{table} rt
|
|
69
|
+
JOIN family f ON rt.id = f.previous_token_id OR rt.previous_token_id = f.id
|
|
70
|
+
)
|
|
71
|
+
SELECT id FROM family
|
|
72
|
+
SQL
|
|
73
|
+
family_ids = self.class.connection.select_values(self.class.sanitize_sql([sql, { id: id }]))
|
|
74
|
+
self.class.where(id: family_ids)
|
|
79
75
|
end
|
|
80
76
|
end
|
|
81
77
|
end
|
|
@@ -53,7 +53,14 @@ module StandardId
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def generate_token_digest
|
|
56
|
-
|
|
56
|
+
configured_cost = StandardId.config.session.token_digest_cost
|
|
57
|
+
self.token_digest =
|
|
58
|
+
if configured_cost.nil?
|
|
59
|
+
BCrypt::Password.create(token)
|
|
60
|
+
else
|
|
61
|
+
cost = configured_cost.clamp(BCrypt::Engine::MIN_COST, BCrypt::Engine::MAX_COST)
|
|
62
|
+
BCrypt::Password.create(token, cost: cost)
|
|
63
|
+
end
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
def generate_lookup_hash
|
|
@@ -8,14 +8,14 @@ module StandardId
|
|
|
8
8
|
if api_session.blank?
|
|
9
9
|
raise StandardId::NotAuthenticatedError, "Invalid or missing access token"
|
|
10
10
|
elsif api_session.respond_to?(:expired?) && api_session.expired?
|
|
11
|
-
emit_session_expired(api_session)
|
|
11
|
+
emit_session_expired(api_session, session_manager: session_manager)
|
|
12
12
|
raise StandardId::ExpiredSessionError, "Session has expired"
|
|
13
13
|
elsif api_session.respond_to?(:revoked?) && api_session.revoked?
|
|
14
14
|
session_manager.clear_session!
|
|
15
15
|
raise StandardId::RevokedSessionError, "Session has been revoked"
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
emit_session_validated(api_session)
|
|
18
|
+
emit_session_validated(api_session, session_manager: session_manager)
|
|
19
19
|
api_session
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -62,34 +62,42 @@ module StandardId
|
|
|
62
62
|
)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
def emit_session_validated(api_session)
|
|
66
|
-
account = if api_session.respond_to?(:account)
|
|
67
|
-
api_session.account
|
|
68
|
-
elsif api_session.respond_to?(:account_id)
|
|
69
|
-
StandardId.account_class.find_by(id: api_session.account_id)
|
|
70
|
-
end
|
|
71
|
-
|
|
65
|
+
def emit_session_validated(api_session, session_manager: nil)
|
|
72
66
|
StandardId::Events.publish(
|
|
73
67
|
StandardId::Events::SESSION_VALIDATED,
|
|
74
68
|
session: api_session,
|
|
75
|
-
account:
|
|
69
|
+
account: resolve_account(api_session, session_manager)
|
|
76
70
|
)
|
|
77
71
|
end
|
|
78
72
|
|
|
79
|
-
def emit_session_expired(api_session)
|
|
80
|
-
account = if api_session.respond_to?(:account)
|
|
81
|
-
api_session.account
|
|
82
|
-
elsif api_session.respond_to?(:account_id)
|
|
83
|
-
StandardId.account_class.find_by(id: api_session.account_id)
|
|
84
|
-
end
|
|
85
|
-
|
|
73
|
+
def emit_session_expired(api_session, session_manager: nil)
|
|
86
74
|
StandardId::Events.publish(
|
|
87
75
|
StandardId::Events::SESSION_EXPIRED,
|
|
88
76
|
session: api_session,
|
|
89
|
-
account:
|
|
77
|
+
account: resolve_account(api_session, session_manager),
|
|
90
78
|
expired_at: api_session.respond_to?(:expires_at) ? api_session.expires_at : nil
|
|
91
79
|
)
|
|
92
80
|
end
|
|
81
|
+
|
|
82
|
+
# Prefer the session_manager's memoized #current_account (resolved once
|
|
83
|
+
# per request). Fall back to a direct lookup only when a session_manager
|
|
84
|
+
# isn't available (e.g. older call sites) or the session lacks an
|
|
85
|
+
# account_id — the cost is kept to a single query per request instead
|
|
86
|
+
# of one per event.
|
|
87
|
+
#
|
|
88
|
+
# NOTE on the fallback: callers that pass no session_manager still pay
|
|
89
|
+
# the pre-optimization cost — either one AR association load per event
|
|
90
|
+
# (via `api_session.account`) or one `find_by` query. The in-gem call
|
|
91
|
+
# site in `require_session!` always passes the session_manager, so the
|
|
92
|
+
# optimization fires for it. The fallback exists for any external
|
|
93
|
+
# caller that invokes emit_session_* directly without a session_manager.
|
|
94
|
+
def resolve_account(api_session, session_manager)
|
|
95
|
+
return session_manager.current_account if session_manager&.respond_to?(:current_account)
|
|
96
|
+
return api_session.account if api_session.respond_to?(:account)
|
|
97
|
+
return unless api_session.respond_to?(:account_id)
|
|
98
|
+
|
|
99
|
+
StandardId.account_class.find_by(id: api_session.account_id)
|
|
100
|
+
end
|
|
93
101
|
end
|
|
94
102
|
end
|
|
95
103
|
end
|
|
@@ -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
|
|
22
|
-
#
|
|
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 "
|
|
4
|
+
require "standard_id/config_schema"
|
|
5
5
|
|
|
6
|
-
|
|
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
|
-
# —
|
|
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"
|
|
@@ -159,6 +159,20 @@ StandardConfig.schema.draw do
|
|
|
159
159
|
field :device_session_lifetime, type: :integer, default: 2592000 # 30 days in seconds
|
|
160
160
|
field :service_session_lifetime, type: :integer, default: 7776000 # 90 days in seconds
|
|
161
161
|
|
|
162
|
+
# BCrypt cost factor for the session token digest. Default `nil` means
|
|
163
|
+
# use bcrypt-ruby's built-in default (cost 12 in production, MIN_COST
|
|
164
|
+
# in the test env). Since session tokens are 256-bit random
|
|
165
|
+
# (`SecureRandom.urlsafe_base64(32)`), any cost >= 10 is well beyond
|
|
166
|
+
# realistic brute-force, and dropping from 12 to 10 saves ~200ms of
|
|
167
|
+
# CPU per session creation. Host apps with many logins-per-second can
|
|
168
|
+
# set this to `10`; apps that value hash work over login latency can
|
|
169
|
+
# leave it alone or raise it.
|
|
170
|
+
#
|
|
171
|
+
# When set, value is clamped to BCrypt::Engine::MIN_COST..MAX_COST.
|
|
172
|
+
# Applies only to newly-created sessions; existing token_digests keep
|
|
173
|
+
# their original cost.
|
|
174
|
+
field :token_digest_cost, type: :integer, default: nil
|
|
175
|
+
|
|
162
176
|
# Callable that resolves the session class to create for a given auth flow.
|
|
163
177
|
# Receives keyword arguments (request:, account:, flow:) and must return one of:
|
|
164
178
|
# StandardId::BrowserSession, StandardId::DeviceSession, StandardId::ServiceSession,
|
|
@@ -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
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -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
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
data/lib/standard_id/version.rb
CHANGED
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
|
-
|
|
70
|
+
yield config if block_given?
|
|
71
|
+
config
|
|
68
72
|
end
|
|
69
73
|
|
|
70
74
|
def register(scope_name, resolver_proc)
|
|
71
|
-
|
|
75
|
+
config.register(scope_name, resolver_proc)
|
|
72
76
|
end
|
|
73
77
|
|
|
74
78
|
def config
|
|
75
|
-
|
|
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.
|
|
4
|
+
version: 0.17.0
|
|
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: '
|
|
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
|
data/lib/standard_config.rb
DELETED
|
@@ -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
|