standard_id 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -1
  3. data/README.md +251 -13
  4. data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
  5. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +13 -3
  6. data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
  7. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  8. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  9. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  10. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  11. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  12. data/app/models/standard_id/application_record.rb +26 -0
  13. data/app/models/standard_id/authorization_code.rb +3 -1
  14. data/app/models/standard_id/client_application.rb +11 -0
  15. data/app/models/standard_id/identifier.rb +1 -0
  16. data/app/models/standard_id/session.rb +2 -1
  17. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  18. data/app/views/standard_id/web/login/show.html.erb +5 -5
  19. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  20. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  21. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  22. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  23. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  24. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  25. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  26. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  27. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  28. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  29. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  30. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  31. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  32. data/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
  33. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  34. data/lib/generators/standard_id/install/templates/standard_id.rb +45 -15
  35. data/lib/standard_id/checks/migrations.rb +61 -0
  36. data/lib/standard_id/config/schema.rb +40 -10
  37. data/lib/standard_id/config_schema.rb +28 -5
  38. data/lib/standard_id/deprecator.rb +17 -0
  39. data/lib/standard_id/engine.rb +21 -0
  40. data/lib/standard_id/instrumentation.rb +49 -0
  41. data/lib/standard_id/migration_check.rb +183 -0
  42. data/lib/standard_id/migration_helpers.rb +65 -0
  43. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  44. data/lib/standard_id/oauth/oauth_session_persistence.rb +66 -24
  45. data/lib/standard_id/oauth/refresh_token_flow.rb +23 -2
  46. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  47. data/lib/standard_id/passwordless.rb +13 -1
  48. data/lib/standard_id/provider_registry.rb +113 -2
  49. data/lib/standard_id/providers/base.rb +222 -8
  50. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  51. data/lib/standard_id/scope_config.rb +24 -7
  52. data/lib/standard_id/testing/provider_examples.rb +117 -0
  53. data/lib/standard_id/testing.rb +1 -0
  54. data/lib/standard_id/version.rb +1 -1
  55. data/lib/standard_id.rb +26 -0
  56. metadata +24 -17
  57. data/config/initializers/migration_helpers.rb +0 -32
@@ -0,0 +1,61 @@
1
+ module StandardId
2
+ module Checks
3
+ # A StandardHealth-compatible readiness check reporting StandardId
4
+ # migrations the host never installed or never ran (see
5
+ # StandardId::MigrationCheck).
6
+ #
7
+ # Duck-typed like StandardAudit::Checks::Retention — no dependency on
8
+ # standard_health; it exposes the `#initialize(name:, critical:)` + `#run`
9
+ # contract the aggregator calls. Register it NON-critical, so a missing
10
+ # index degrades /health/ready (HTTP 200) instead of failing the probe:
11
+ #
12
+ # c.register_check :standard_id_migrations,
13
+ # StandardId::Checks::Migrations,
14
+ # critical: false
15
+ #
16
+ # Cheap: one directory scan plus one `schema_migrations` read, and once
17
+ # everything is present the :ok result is memoized for the life of the
18
+ # process (migration files cannot change without a deploy).
19
+ class Migrations
20
+ attr_reader :name
21
+
22
+ def initialize(name: :standard_id_migrations, critical: false)
23
+ @name = name
24
+ @critical = critical
25
+ end
26
+
27
+ def critical? = !!@critical
28
+
29
+ def run
30
+ return { status: :ok } if self.class.all_present?
31
+
32
+ all_missing = StandardId::MigrationCheck.pending(check_database: true)
33
+ if all_missing.empty?
34
+ self.class.all_present = true
35
+ return { status: :ok }
36
+ end
37
+
38
+ # Deferred upgrade steps (MigrationCheck::DEFERRED_UPGRADE_STEPS) are
39
+ # reported but never degrade readiness.
40
+ deferred, missing = all_missing.partition(&:info?)
41
+ if missing.empty?
42
+ return { status: :ok, pending_upgrade_steps: deferred.map(&:to_s) }
43
+ end
44
+
45
+ {
46
+ status: :warn,
47
+ message: "#{missing.size} StandardId migration(s) missing: #{missing.map(&:to_s).join(', ')}",
48
+ missing: missing.map { |m| { name: m.name, version: m.version, state: m.state } }
49
+ }
50
+ rescue StandardError => e
51
+ { status: :fail, error: e.message, error_class: e.class.name }
52
+ end
53
+
54
+ class << self
55
+ attr_writer :all_present
56
+
57
+ def all_present? = !!@all_present
58
+ end
59
+ end
60
+ end
61
+ end
@@ -9,8 +9,11 @@ StandardId::ConfigSchema.define do
9
9
  field :cache_store, type: :any, default: nil
10
10
  field :logger, type: :any, default: nil
11
11
  field :web_layout, type: :string, default: nil
12
- field :passwordless_email_sender, type: :any, default: nil
13
- field :passwordless_sms_sender, type: :any, default: nil
12
+ # Deprecated since 0.1.7 (see docs/MIGRATION_GUIDE.md), still honoured.
13
+ field :passwordless_email_sender, type: :any, default: nil,
14
+ deprecated: "deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber instead (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and call Otp.issue with its default delivery: :built_in. Removal in v2.0; see docs/MIGRATION_GUIDE.md."
15
+ field :passwordless_sms_sender, type: :any, default: nil,
16
+ deprecated: "deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber instead (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and call Otp.issue with its default delivery: :built_in. Removal in v2.0; see docs/MIGRATION_GUIDE.md."
14
17
  field :issuer, type: :string, default: nil
15
18
 
16
19
  # Whether `JwtService.decode` REQUIRES a matching `iss` claim.
@@ -55,6 +58,17 @@ StandardId::ConfigSchema.define do
55
58
  # loading for every app that never asked for it.
56
59
  field :association_strict_loading, type: :any, default: nil
57
60
 
61
+ # Boot-time check for StandardId migrations the host never copied in
62
+ # (StandardId::MigrationCheck). :warn, :raise or :ignore. nil (default)
63
+ # means :warn in development/test and :ignore elsewhere — it never raises
64
+ # in production unless you ask it to.
65
+ field :missing_migrations, type: :symbol, default: nil
66
+
67
+ # Gem migration names (e.g. "add_target_created_at_index_to_code_challenges")
68
+ # or original versions the check should skip — for a migration the host
69
+ # deliberately superseded or deferred.
70
+ field :ignored_migrations, type: :array, default: -> { [] }
71
+
58
72
  # Scope-aware authentication: maps scope names to profile-based access config.
59
73
  # Each scope is a hash with keys: :profile_types (Array<String>), :after_sign_in_path,
60
74
  # :no_profile_message, :label, :allow_registration, :authorizer.
@@ -139,9 +153,11 @@ StandardId::ConfigSchema.define do
139
153
  end
140
154
 
141
155
  scope :passwordless do
142
- # Deprecated: use web.passwordless_login to control WebEngine passwordless login.
143
- # Retained for backwards compatibility with consuming apps that set this field.
144
- field :enabled, type: :boolean, default: false
156
+ # Deprecated since 0.8: use web.passwordless_login to control WebEngine
157
+ # passwordless login. Never read; retained so host initializers that set it
158
+ # still boot.
159
+ field :enabled, type: :boolean, default: false,
160
+ deprecated: "it has had no effect since 0.8. Use web.passwordless_login (WebEngine) instead, and remove this line. Removal in v2.0."
145
161
  field :connection, type: :string, default: "email"
146
162
  field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
147
163
 
@@ -162,7 +178,10 @@ StandardId::ConfigSchema.define do
162
178
  # rate limit (config.rate_limits.otp_verify_per_ip) — the per-IP limit
163
179
  # prevents brute-forcing from a single source, while this ceiling defends
164
180
  # against distributed brute-force attempts against the same challenge.
165
- # When nil, falls back to :max_attempts for backwards compatibility.
181
+ # When nil, falls back to :max_attempts for backwards compatibility, so the
182
+ # effective default is 3 (:max_attempts' default), not 5. 5 is only the
183
+ # last resort when both are unset or non-positive
184
+ # (StandardId::Passwordless::FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE).
166
185
  field :max_attempts_per_challenge, type: :integer, default: nil
167
186
 
168
187
  field :retry_delay, type: :integer, default: 30 # 30 seconds
@@ -293,8 +312,12 @@ StandardId::ConfigSchema.define do
293
312
  # RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS.
294
313
  field :refresh_token_reuse_leeway, type: :integer, default: 0
295
314
  field :token_lifetimes, type: :hash, default: -> { {} }
296
- field :client_id, type: :string, default: nil
297
- field :client_secret, type: :string, default: nil
315
+ # Never read by the gem. OAuth clients are StandardId::ClientApplication
316
+ # rows with ClientSecretCredential secrets.
317
+ field :client_id, type: :string, default: nil,
318
+ deprecated: "it is not read anywhere — OAuth clients are StandardId::ClientApplication records. Remove this line. Removal in v2.0."
319
+ field :client_secret, type: :string, default: nil,
320
+ deprecated: "it is not read anywhere — client secrets are StandardId::ClientSecretCredential records. Remove this line. Removal in v2.0."
298
321
  field :scope_claims, type: :hash, default: -> { {} }
299
322
  field :claim_resolvers, type: :hash, default: -> { {} }
300
323
  # List of audience values that tokens issued and accepted by this app may
@@ -548,6 +571,11 @@ StandardId::ConfigSchema.define do
548
571
  field :allowed_redirect_url_prefixes, type: :array, default: []
549
572
  field :available_scopes, type: :array, default: -> { [] }
550
573
  field :link_strategy, type: :symbol, default: :strict
574
+ # What to do at boot when an enabled social provider is missing required
575
+ # config (e.g. apple_client_id set without apple_private_key). :warn logs
576
+ # in every environment; :raise raises StandardId::ConfigurationError in
577
+ # production and logs elsewhere. See ProviderRegistry.validate_configuration!
578
+ field :provider_misconfiguration, type: :symbol, default: :warn
551
579
  end
552
580
 
553
581
  scope :web do
@@ -580,8 +608,10 @@ StandardId::ConfigSchema.define do
580
608
  # these values (see StandardId::RateLimitHandling.login_per_ip). New name
581
609
  # wins when explicitly set. Mirrors the max_attempts ->
582
610
  # max_attempts_per_challenge deprecation-alias precedent.
583
- field :password_login_per_ip, type: :integer, default: 20 # per 15 minutes; deprecated alias of login_per_ip
584
- field :password_login_per_email, type: :integer, default: 5 # per 15 minutes; deprecated alias of login_per_email
611
+ field :password_login_per_ip, type: :integer, default: 20, # per 15 minutes; deprecated alias of login_per_ip
612
+ deprecated: "use rate_limits.login_per_ip (same meaning; it also governs passwordless OTP sends). Removal in v2.0."
613
+ field :password_login_per_email, type: :integer, default: 5, # per 15 minutes; deprecated alias of login_per_email
614
+ deprecated: "use rate_limits.login_per_email (same meaning; it also governs passwordless OTP sends). Removal in v2.0."
585
615
 
586
616
  # RAR-60: OTP verification
587
617
  field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
@@ -1,5 +1,6 @@
1
1
  require "active_support/ordered_options"
2
2
  require "concurrent/map"
3
+ require "standard_id/deprecator"
3
4
 
4
5
  module StandardId
5
6
  # Lightweight configuration schema backed by ActiveSupport::OrderedOptions.
@@ -9,7 +10,10 @@ module StandardId
9
10
  # to the owning scope (so host apps can read base-scope fields like
10
11
  # `config.account_class_name` without the `base.` prefix).
11
12
  class ConfigSchema
12
- Field = Struct.new(:name, :type, :default) do
13
+ # +deprecation+ is a message (String) for a field kept only so existing
14
+ # host initializers still boot. Assigning a non-nil value warns through
15
+ # StandardId.deprecator; reads and schema defaults never warn.
16
+ Field = Struct.new(:name, :type, :default, :deprecation) do
13
17
  def default_value
14
18
  return default.call if default.respond_to?(:call)
15
19
  return default.dup if default.is_a?(Array) || default.is_a?(Hash)
@@ -35,9 +39,9 @@ module StandardId
35
39
  self
36
40
  end
37
41
 
38
- def add_field(scope:, name:, type: :string, default: nil)
42
+ def add_field(scope:, name:, type: :string, default: nil, deprecated: nil)
39
43
  fields = ensure_scope(scope)
40
- fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default) }
44
+ fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default, deprecated) }
41
45
  end
42
46
 
43
47
  # Register a scope without adding a field. Allows `define { scope :foo }` so
@@ -100,8 +104,8 @@ module StandardId
100
104
  DSL.new(@schema, name.to_sym).instance_eval(&block) if block
101
105
  end
102
106
 
103
- def field(name, type: :string, default: nil, **)
104
- @schema.add_field(scope: @scope_name, name: name, type: type, default: default)
107
+ def field(name, type: :string, default: nil, deprecated: nil, **)
108
+ @schema.add_field(scope: @scope_name, name: name, type: type, default: default, deprecated: deprecated)
105
109
  end
106
110
  end
107
111
 
@@ -122,6 +126,7 @@ module StandardId
122
126
 
123
127
  def []=(key, value)
124
128
  validate!(key)
129
+ warn_if_deprecated(key, value)
125
130
  super(key.to_sym, value)
126
131
  end
127
132
 
@@ -154,6 +159,24 @@ module StandardId
154
159
  "Unknown field '#{key}' for scope '#{@scope_name}'. Valid fields: #{@schema.scopes[@scope_name]&.keys}"
155
160
  end
156
161
 
162
+ def warn_if_deprecated(key, value)
163
+ return if value.nil?
164
+
165
+ message = @schema.field_for(@scope_name, key)&.deprecation
166
+ return if message.nil?
167
+
168
+ # Point the warning at the host's assignment, not at this file.
169
+ # (OrderedOptions' method_missing forwards `c.foo = x` to #[]=.)
170
+ callstack = caller_locations(1).reject do |location|
171
+ location.path == __FILE__ || location.path.end_with?("active_support/ordered_options.rb")
172
+ end
173
+ StandardId.deprecator.warn("StandardId.config.#{config_path(key)} is deprecated: #{message}", callstack)
174
+ end
175
+
176
+ def config_path(key)
177
+ @scope_name == :base ? key.to_s : "#{@scope_name}.#{key}"
178
+ end
179
+
157
180
  def cast_read(key, value)
158
181
  field = @schema.field_for(@scope_name, key)
159
182
  return value unless field
@@ -0,0 +1,17 @@
1
+ require "active_support/deprecation"
2
+
3
+ module StandardId
4
+ # The gem's single ActiveSupport::Deprecation instance.
5
+ #
6
+ # The engine registers it in `Rails.application.deprecators[:standard_id]`,
7
+ # so the host's `config.active_support.deprecation` behaviour (:raise in
8
+ # test, :log / :notify in production, ...) and
9
+ # `Rails.application.deprecators.silence` apply to StandardId warnings exactly
10
+ # as they do to Rails' own. An unregistered deprecator only ever printed to
11
+ # stderr, whatever the host configured.
12
+ #
13
+ # @return [ActiveSupport::Deprecation]
14
+ def self.deprecator
15
+ @deprecator ||= ActiveSupport::Deprecation.new("2.0", "StandardId")
16
+ end
17
+ end
@@ -37,6 +37,26 @@ module StandardId
37
37
  StandardId::ProviderRegistry.declare_config_schemas!
38
38
  end
39
39
 
40
+ # Route StandardId deprecation warnings through the host's deprecation
41
+ # behaviour (`config.active_support.deprecation`, `report_deprecations`,
42
+ # `Rails.application.deprecators.silence`).
43
+ initializer "standard_id.deprecator" do |app|
44
+ app.deprecators[:standard_id] = StandardId.deprecator if app.respond_to?(:deprecators)
45
+ end
46
+
47
+ # Check every enabled social provider is fully configured, once all of
48
+ # them have registered.
49
+ #
50
+ # Provider plugins register from `config.after_initialize` hooks added
51
+ # when their gem is required — before any initializer runs. A hook added
52
+ # from inside an initializer is appended after all of those, so it sees
53
+ # the complete registry.
54
+ initializer "standard_id.validate_social_providers" do |app|
55
+ app.config.after_initialize do
56
+ StandardId::ProviderRegistry.validate_configuration!
57
+ end
58
+ end
59
+
40
60
  initializer "standard_id.filter_parameters" do |app|
41
61
  app.config.filter_parameters += %i[
42
62
  code_verifier
@@ -79,6 +99,7 @@ module StandardId
79
99
  StandardId::Config::ScopeClaimsValidator.validate!
80
100
 
81
101
  StandardId::Engine.verify_host_cookie_encryption!(app)
102
+ StandardId::MigrationCheck.verify_at_boot!
82
103
  StandardId::Engine.warn_if_allowed_audiences_empty_in_production!
83
104
  end
84
105
 
@@ -0,0 +1,49 @@
1
+ require "active_support/notifications"
2
+
3
+ module StandardId
4
+ # ActiveSupport::Notifications hooks around the expensive, opaque steps of
5
+ # the OAuth token endpoint, so hosts can attach tracing spans (Sentry,
6
+ # OpenTelemetry, Datadog) or timing metrics without prepending onto private
7
+ # gem methods.
8
+ #
9
+ # Names follow the Rails `<event>.<library>` convention (like
10
+ # `process_action.action_controller`), deliberately distinct from the
11
+ # `standard_id.<domain>.<event>` names StandardId::Events publishes: these
12
+ # are timing hooks, not audit events, and must not reach audit subscribers
13
+ # listening on `standard_id.*`.
14
+ #
15
+ # Every event is a block instrument, so subscribers get start/finish (and
16
+ # `:exception` / `:exception_object` in the payload when the step raised).
17
+ # Events nest: AUDIENCE_PROFILE_RESOLVE fires inside AUDIENCE_PROFILE_BINDING.
18
+ #
19
+ # Subscribe to all of them with:
20
+ #
21
+ # ActiveSupport::Notifications.subscribe(StandardId::Instrumentation::PATTERN) { |event| ... }
22
+ module Instrumentation
23
+ # TokenGrantFlow#authenticate! — client authentication plus grant
24
+ # validation (for refresh_token: JWT decode + token row lookup + reuse
25
+ # detection). Payload: :flow (class name), :grant_type.
26
+ AUTHENTICATE = "authenticate.standard_id".freeze
27
+
28
+ # TokenGrantFlow#enforce_audience_profile_binding! — the account load and
29
+ # profile resolution for audience→profile binding. Fires on every token
30
+ # grant, including when no binding is configured (then it is a no-op).
31
+ # Payload: :flow, :grant_type, :audience (Array<String>).
32
+ AUDIENCE_PROFILE_BINDING = "audience_profile_binding.standard_id".freeze
33
+
34
+ # Oauth::AudienceProfileResolver.resolve! — just the resolver call (the
35
+ # host's `oauth.audience_profile_resolver` or the built-in strict lookup).
36
+ # Payload: :audience (String).
37
+ AUDIENCE_PROFILE_RESOLVE = "audience_profile_resolve.standard_id".freeze
38
+
39
+ EVENTS = [AUTHENTICATE, AUDIENCE_PROFILE_BINDING, AUDIENCE_PROFILE_RESOLVE].freeze
40
+
41
+ # Matches every instrumentation event above and nothing StandardId::Events
42
+ # publishes.
43
+ PATTERN = /\.standard_id\z/
44
+
45
+ def self.instrument(name, payload = {}, &)
46
+ ActiveSupport::Notifications.instrument(name, payload, &)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,183 @@
1
+ module StandardId
2
+ # Detects StandardId migrations a host never installed (or installed but
3
+ # never ran).
4
+ #
5
+ # WHY
6
+ #
7
+ # `bin/rails standard_id:install:migrations` copies the engine's migrations
8
+ # into the host with NEW timestamps. Rails' pending-migration check only
9
+ # sees files that are in the host's db/migrate, so a gem migration that was
10
+ # never copied is invisible to it — fundbright-web, luminality-web and
11
+ # nutripod-web each ran for months without 20260416180511's indexes, and
12
+ # two still lack 20260414200000. This compares by migration NAME (the part
13
+ # after the timestamp, with or without the `.standard_id` suffix Rails adds),
14
+ # which survives the re-timestamping.
15
+ #
16
+ # COST
17
+ #
18
+ # A Dir.glob over the host's migration paths; the database is only consulted
19
+ # (one `schema_migrations` read) when `check_database: true`, which the boot
20
+ # check never passes.
21
+ module MigrationCheck
22
+ GEM_MIGRATIONS_PATH = File.expand_path("../../db/migrate", __dir__)
23
+ FILENAME = /\A(\d+)_(\w+?)(?:\.[a-z_]+)?\.rb\z/
24
+
25
+ # A gem migration whose job is fully done by a LATER gem migration, so a
26
+ # host that installed the later one may skip it. name => superseding name.
27
+ #
28
+ # 20260414200000 adds a plain, non-concurrent 4-column index on
29
+ # standard_id_code_challenges; 20260416180511 adds the partial
30
+ # `index_code_challenges_on_active_target_created_at` (same columns,
31
+ # WHERE used_at IS NULL, built CONCURRENTLY) that serves the same lookups.
32
+ # Hosts on busy tables (fundbright-web, luminality-web) skipped the former
33
+ # on purpose rather than take the write lock.
34
+ SUPERSEDED_BY = {
35
+ "add_target_created_at_index_to_code_challenges" =>
36
+ "add_partial_indexes_for_active_session_and_challenge_lookups"
37
+ }.freeze
38
+
39
+ # Gem migrations hosts are EXPECTED to hold back for a while — reported as
40
+ # a pending upgrade step (severity :info), never as an error, so they do
41
+ # not warn at boot, fail boot in :raise mode, or degrade the health check.
42
+ #
43
+ # 20260915000000 drops a column 0.41.1 ignores; per the 0.41.1 upgrade
44
+ # notes it must only run once 0.41.1+ is deployed everywhere.
45
+ DEFERRED_UPGRADE_STEPS = {
46
+ "remove_refresh_token_lifetime_from_standard_id_client_applications" =>
47
+ "run once StandardId >= 0.41.1 is deployed to every process (it drops a column 0.41.1 ignores)"
48
+ }.freeze
49
+
50
+ # state: :not_installed — no host migration file with this name
51
+ # :not_run — the host file exists but its version is not in schema_migrations
52
+ # severity: :error — a migration the host should have
53
+ # :info — a DEFERRED_UPGRADE_STEPS entry: pending, but intentionally
54
+ Missing = Data.define(:name, :version, :state, :severity) do
55
+ def initialize(name:, version:, state:, severity: :error) = super
56
+
57
+ def info? = severity == :info
58
+
59
+ def to_s
60
+ label = state.to_s.tr("_", " ")
61
+ info? ? "#{version}_#{name} (#{label}; pending upgrade step: #{DEFERRED_UPGRADE_STEPS[name]})" : "#{version}_#{name} (#{label})"
62
+ end
63
+ end
64
+
65
+ MODES = %i[warn raise ignore].freeze
66
+
67
+ module_function
68
+
69
+ # @return [Array<Array(String, String)>] [[original_version, name], ...] for every gem migration
70
+ def gem_migrations
71
+ @gem_migrations ||= parse_dir(GEM_MIGRATIONS_PATH).sort.freeze
72
+ end
73
+
74
+ # @param paths [Array<String>] host migration directories
75
+ # @param check_database [Boolean] also report installed-but-unrun migrations
76
+ # @param ignore [Array<String>] migration names (or original versions) to skip
77
+ # @return [Array<Missing>]
78
+ def pending(paths: host_migration_paths, check_database: false, ignore: StandardId.config.ignored_migrations)
79
+ ignore = Array(ignore).map(&:to_s)
80
+ host = host_versions_by_name(paths)
81
+ applied = check_database ? applied_versions : nil
82
+
83
+ present = ->(migration_name) { present_in_host?(host[migration_name], applied) }
84
+
85
+ gem_migrations.filter_map do |version, name|
86
+ next if ignore.include?(name) || ignore.include?(version)
87
+
88
+ host_versions = host[name]
89
+ state = if host_versions.blank?
90
+ :not_installed
91
+ elsif applied && (host_versions & applied).empty?
92
+ :not_run
93
+ end
94
+ next if state.nil?
95
+ next if (successor = SUPERSEDED_BY[name]) && present.call(successor)
96
+
97
+ severity = DEFERRED_UPGRADE_STEPS.key?(name) ? :info : :error
98
+ Missing.new(name: name, version: version, state: state, severity: severity)
99
+ end
100
+ end
101
+
102
+ # Installed (and, when +applied+ is given, run).
103
+ def present_in_host?(host_versions, applied)
104
+ return false if host_versions.blank?
105
+
106
+ applied.nil? || (host_versions & applied).any?
107
+ end
108
+
109
+ # The mode in effect: config.missing_migrations, else :warn in
110
+ # development/test and :ignore everywhere else (never raise in production
111
+ # by default).
112
+ def mode
113
+ configured = StandardId.config.missing_migrations
114
+ return configured.to_sym if configured.present?
115
+
116
+ Rails.env.local? ? :warn : :ignore
117
+ end
118
+
119
+ # Called from the engine's after_initialize. File-system only — boot must
120
+ # not need a database (assets:precompile, db:create).
121
+ #
122
+ # @raise [StandardId::ConfigurationError] in :raise mode when migrations are missing
123
+ def verify_at_boot!
124
+ current = mode
125
+ unless MODES.include?(current)
126
+ raise StandardId::ConfigurationError,
127
+ "StandardId.config.missing_migrations must be one of #{MODES.inspect} (got #{current.inspect})"
128
+ end
129
+ return if current == :ignore
130
+
131
+ all_missing = pending
132
+ deferred, missing = all_missing.partition(&:info?)
133
+ Rails.logger.info(deferred_message(deferred)) if deferred.any?
134
+ return if missing.empty?
135
+
136
+ message = boot_message(missing)
137
+ raise StandardId::ConfigurationError, message if current == :raise
138
+
139
+ Rails.logger.warn(message)
140
+ warn(message) if Rails.env.local?
141
+ end
142
+
143
+ def boot_message(missing)
144
+ <<~MESSAGE.strip
145
+ [StandardId] #{missing.size} StandardId migration(s) are not installed in this app:
146
+ #{missing.map(&:to_s).join("\n ")}
147
+ Run `bin/rails standard_id:install:migrations && bin/rails db:migrate`.
148
+ If one is deliberately skipped (e.g. superseded by a host migration), list its name in
149
+ `StandardId.config.ignored_migrations`. Set `config.missing_migrations = :raise` to fail
150
+ boot instead, or `:ignore` to silence this check.
151
+ MESSAGE
152
+ end
153
+
154
+ def deferred_message(deferred)
155
+ "[StandardId] Pending upgrade step(s), held back intentionally: #{deferred.map(&:to_s).join('; ')}"
156
+ end
157
+
158
+ def host_migration_paths
159
+ paths = Rails.application.paths["db/migrate"].existent
160
+ paths += Array(ActiveRecord::Migrator.migrations_paths).map { |p| File.expand_path(p, Rails.root) }
161
+ paths.uniq.select { |p| File.directory?(p) }
162
+ end
163
+
164
+ def host_versions_by_name(paths)
165
+ Array(paths).flat_map { |dir| parse_dir(dir) }.each_with_object({}) do |(version, name), acc|
166
+ (acc[name] ||= []) << version
167
+ end
168
+ end
169
+
170
+ def parse_dir(dir)
171
+ Dir.children(dir).filter_map do |file|
172
+ match = FILENAME.match(file)
173
+ [match[1], match[2]] if match
174
+ end
175
+ rescue Errno::ENOENT
176
+ []
177
+ end
178
+
179
+ def applied_versions
180
+ ActiveRecord::Base.connection_pool.schema_migration.versions
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,65 @@
1
+ require "active_support/concern"
2
+
3
+ module StandardId
4
+ # `primary_key_type` / `foreign_key_type` for StandardId's own migrations:
5
+ # the host's generator `primary_key_type` (e.g. :uuid), else :bigint.
6
+ #
7
+ # These used to be monkey-patched onto ActiveRecord::Migration itself, so
8
+ # every migration in every host app gained three methods it never asked for.
9
+ # They are now scoped to StandardId migrations:
10
+ #
11
+ # * the gem's migrations `include StandardId::MigrationHelpers` explicitly
12
+ # (so newly installed copies carry the include with them), and
13
+ # * copies installed BEFORE that line existed — which call the helpers with
14
+ # no include — are recognised by file name (a StandardId migration name,
15
+ # with or without the `.standard_id` suffix) when their class is defined,
16
+ # and get the module then. No other migration is touched.
17
+ module MigrationHelpers
18
+ extend ActiveSupport::Concern
19
+
20
+ class_methods do
21
+ def primary_and_foreign_key_types
22
+ config = Rails.configuration.generators
23
+ config.options[config.orm][:primary_key_type] || :bigint
24
+ end
25
+
26
+ def primary_key_type = primary_and_foreign_key_types
27
+ def foreign_key_type = primary_and_foreign_key_types
28
+ end
29
+
30
+ def primary_and_foreign_key_types = self.class.primary_and_foreign_key_types
31
+ def primary_key_type = self.class.primary_key_type
32
+ def foreign_key_type = self.class.foreign_key_type
33
+
34
+ # Is +path+ a (possibly host-copied) StandardId migration file?
35
+ def self.standard_id_migration_file?(path)
36
+ return false if path.nil?
37
+
38
+ match = StandardId::MigrationCheck::FILENAME.match(File.basename(path))
39
+ return false unless match
40
+
41
+ gem_migration_names.include?(match[2])
42
+ end
43
+
44
+ def self.gem_migration_names
45
+ @gem_migration_names ||= StandardId::MigrationCheck.gem_migrations.to_set(&:last).freeze
46
+ end
47
+
48
+ # Prepended onto ActiveRecord::Migration's singleton class. `inherited`
49
+ # fires while the migration's `class ... < ActiveRecord::Migration[x]`
50
+ # line runs, so the caller location is the migration file itself.
51
+ module LegacyCopySupport
52
+ def inherited(subclass)
53
+ super
54
+ path = caller_locations(1, 1).first&.path
55
+ return unless StandardId::MigrationHelpers.standard_id_migration_file?(path)
56
+
57
+ subclass.include(StandardId::MigrationHelpers)
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ ActiveSupport.on_load(:active_record) do
64
+ ActiveRecord::Migration.singleton_class.prepend(StandardId::MigrationHelpers::LegacyCopySupport)
65
+ end
@@ -78,6 +78,14 @@ module StandardId
78
78
  # @raise [StandardId::NoBoundProfileError]
79
79
  # @raise [StandardId::AmbiguousProfileError]
80
80
  def resolve!(account:, audience:)
81
+ StandardId::Instrumentation.instrument(
82
+ StandardId::Instrumentation::AUDIENCE_PROFILE_RESOLVE, audience: audience
83
+ ) { resolve_bound_profile!(account, audience) }
84
+ end
85
+
86
+ private
87
+
88
+ def resolve_bound_profile!(account, audience)
81
89
  types = profile_types_for(audience)
82
90
  raise ArgumentError, "audience #{audience.inspect} has no profile binding" if types.empty?
83
91
 
@@ -101,8 +109,6 @@ module StandardId
101
109
  strict_default_lookup(account, audience, types)
102
110
  end
103
111
 
104
- private
105
-
106
112
  def default_lookup(account, types)
107
113
  return nil unless account.respond_to?(:profiles)
108
114