standard_id 0.42.0 → 0.43.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e1701f28a06762afc1eac4d5d31408180ab6b89bf617d420124301ad55c0f8b
4
- data.tar.gz: be9d2d78656afd4d0d2e67e17bbf1fdb3cd6c586af6ad85e5514385b1b52f025
3
+ metadata.gz: 8f9798d4a8c4db5cbc69cc0c4f569cc41e825b5df159124fab15f39923ca602f
4
+ data.tar.gz: 1b06dc3eee0795f25633df9dc93dae5376c0bc5f6e1b0cd97ef3ce37fca6fc02
5
5
  SHA512:
6
- metadata.gz: 924d298113db49b3014dd440b49d038476f23bab8474cf02a661839079580a4ee772c8cdaeb0cf330bfe85fa437b6042af1ffbf1d299d8a4ecbf7ac091c0bafa
7
- data.tar.gz: a9f4da6de783d8e7d0c8f862c2a5ca80c708856746a45ec61605f51160ef8d85b596b1fbad7b75bda3e5cae6311061fb43424db74005e0c24d9ba7fb3a4c55ab
6
+ metadata.gz: 10594cb5f3206999becca9fb582bfcc714f8371713160192ddf72fdeb39fe4f9552e9d551991fcf294af2eca71fcd8266f8c1f0a27e94c069a7f597581a54d8e
7
+ data.tar.gz: 1ababa07f26cea7f6240666372f25ada38a7c3672313306c1ce7eddedded43efaff08b27df71d50c1b78f75e75d6f909f421236c455d1ac9c1658015752bdc4c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.43.0] - 2026-09-24
11
+
12
+ **Breaking minor.** Removes everything 0.42 deprecated. Every consumer cleared those warnings in its Phase 3 adoption, so for the five apps the upgrade is a version bump — see **Upgrade notes**.
13
+
14
+ ### Upgrade notes
15
+
16
+ If your app boots on 0.42 with no `StandardId` deprecation warning, **you need change nothing**. Checked against `origin/main` of fundbright-web, jumpdrive-web, luminality-web, nutripod-web and sidekick-web: none assigns a removed setting, uses the singular scope `profile_type:`, calls `Otp.issue(delivery: :custom)`, defines `Providers::Base.setup` or references a `DEPRECATOR` constant. What remains is comments (jumpdrive-web and luminality-web's initializers still carry the old commented-out `# c.passwordless_*_sender` lines; jumpdrive-web the commented `# c.rate_limits.password_login_per_*` lines) — worth deleting, harmless to keep.
17
+
18
+ | Removed | What to do instead |
19
+ |---|---|
20
+ | `c.passwordless_email_sender`, `c.passwordless_sms_sender` | A `StandardId::Events::PASSWORDLESS_CODE_GENERATED` subscriber (skip when `event[:skip_sender]`), with `c.passwordless.delivery = :custom` (the default) — as fundbright-web already does. |
21
+ | `c.passwordless.enabled` | `c.web.passwordless_login` (it had no effect since 0.8). |
22
+ | `c.oauth.client_id`, `c.oauth.client_secret` | Delete — never read. |
23
+ | `c.rate_limits.password_login_per_ip`, `password_login_per_email` | `c.rate_limits.login_per_ip`, `login_per_email`. `RateLimitHandling.login_per_ip` / `.login_per_email` now read only these. |
24
+ | Scope config `profile_type: "X"` | `profile_types: ["X"]`. |
25
+ | `Providers::Base.setup` (called with a warning when a provider defined it) | Initialize in the plugin's Railtie; `setup` is no longer called. standard_id-apple 0.6, standard_id-google 0.5 and standard_id-provider define none. |
26
+ | `StandardId::ScopeConfig::DEPRECATOR`, `StandardId::ProviderRegistry::DEPRECATOR` | `StandardId.deprecator` (still registered as `Rails.application.deprecators[:standard_id]`). |
27
+
28
+ Assigning a removed setting now raises `StandardId::ConfigurationError` **at boot**, with the replacement in the message (`StandardId.config.oauth.client_id was removed in StandardId 0.43: …`), rather than the generic "Unknown field" — and, for the two base-scope senders written through the top-level config, rather than being silently stored and ignored. Reading `StandardId.config.passwordless_email_sender` still returns `nil`, so a host spec asserting it is unset (fundbright-web's `spec/lib/otp_delivery_spec.rb`) keeps passing. A scope still using `profile_type:` raises at boot too (`ScopeConfig.validate_all!`, run by the engine): ignoring the key would have left the scope with no profile requirement at all.
29
+
30
+ `ignored_columns` for `ClientApplication#refresh_token_lifetime` is **kept**. All five apps have merged the column-drop migration, but it only runs with each app's next production deploy, and ignoring an absent column is harmless. It goes in a later minor.
31
+
32
+ `ostruct` was already dropped from the runtime dependencies in 0.42 (nothing in `app/` or `lib/` uses `OpenStruct`); it stays a development dependency for the specs.
33
+
34
+ ### Changed
35
+
36
+ - **`StandardId::Otp.issue(delivery: :custom)` no longer needs a sender callback.** It hard-required `passwordless_email_sender` / `passwordless_sms_sender` (raising `ConfigurationError` without one) even after 0.42 deprecated them. It now publishes `PASSWORDLESS_CODE_GENERATED` like the other modes, with `delivery: :custom` in the payload; the engine's `PasswordlessDeliverySubscriber` skips such events even when `c.passwordless.delivery` is `:built_in`, so the host's own subscriber is the only one that delivers. Nothing is sent unless the host subscribes. The YARD docs and README table, which still said `:custom` "calls `passwordless_email_sender`", are fixed.
37
+ - **`PASSWORDLESS_CODE_GENERATED` payload gains `delivery:`** — the `Otp.issue` mode (`:built_in` / `:custom` / `:manual`), `nil` when the code was not issued through `Otp.issue` (sign-in). `skip_sender` is unchanged.
38
+ - **WebEngine `verify_email` / `verify_phone` start actions issue their code through `Otp.issue`** (realm `"verification"`, 10-minute expiry as before), so delivery goes through the event — the built-in mailer under `delivery: :built_in`, the host subscriber otherwise. They used to call the sender callbacks directly and sent nothing without them. They now also get the strategy's format validation, `username_validator`, retry-delay cooldown and previous-code invalidation; a rejected target renders 422.
39
+ - The passwordless strategies no longer have a `sender_callback`.
40
+
41
+ ### Added
42
+
43
+ - **`StandardId::CleanupAllJob`** runs the four cleanup jobs inline — one recurring entry, so the schedule can carry one cron monitor (the gem's jobs carry none). A failure in one does not skip the rest; the first error is re-raised afterwards so the run still fails its check-in. Subclass it to attach a Sentry cron monitor — README *Scheduled Maintenance* has the snippet. This is jumpdrive-web's `StandardIdCleanupJob`, upstreamed: jumpdrive-web can make its job a subclass (keeping its monitor); the other four apps can collapse their four entries into one if they want a monitor.
44
+ - **`ConfigSchema::Scope#assigned?(field)`** — true only when the host assigned the field (even to `nil`). `key?` is true for every declared field, since defaults are written at config build, so it could not tell a provider field set in the initializer from one falling back to ENV.
45
+ - **`ConfigSchema::Scope#refresh_defaults!`** re-resolves the default of every unassigned field — including provider ENV fallbacks, which are otherwise read once at boot.
46
+ - **`StandardId::Testing::ConfigHelpers#with_provider_env`** (`require "standard_id/testing"`; also `StandardId::Testing.with_provider_env`): sets ENV variables, re-resolves unassigned fields, runs the block and restores both, so a host can test "set `APPLE_CLIENT_ID` → provider enabled". The provider round-trip helper now restores a never-assigned field via `assigned?`.
47
+ - `ConfigSchema` DSL `removed :name, "hint"` for settings taken out of the schema.
48
+
49
+ ### Documentation
50
+
51
+ - README Sentry span snippet: `finish` now guards `set_span(parent)` (`Scope#set_span` raises `ArgumentError` on `nil`) and says why a missing parent means no span.
52
+ - README: ENV-fallback resolution timing, `assigned?` and `with_provider_env`; the passwordless-delivery section shows the `skip_sender` guard and every payload key.
53
+ - `docs/MIGRATION_GUIDE.md`: a 0.42 → 0.43 section.
54
+
10
55
  ## [0.42.0] - 2026-09-24
11
56
 
12
57
  ### Upgrade
data/README.md CHANGED
@@ -299,6 +299,30 @@ Assign a field only to read it from somewhere else — a differently named
299
299
  variable, Rails credentials. An explicit assignment, even of `nil`, always wins
300
300
  over the ENV fallback.
301
301
 
302
+ The fallback is resolved **once, when the config is built at boot** — setting
303
+ `ENV` later (in a spec, say) changes nothing by itself. Two helpers (0.43+):
304
+
305
+ - `StandardId.config.social.assigned?(:apple_client_id)` is true only when the
306
+ host assigned the field. (`key?` is true for every declared field, assigned or
307
+ not, because defaults are written into the scope at build time.)
308
+ - In specs, `with_provider_env` (from `require "standard_id/testing"`) sets the
309
+ variables, re-resolves every unassigned field, runs the block and restores
310
+ both — so "set the env var → provider enabled" is testable:
311
+
312
+ ```ruby
313
+ RSpec.describe "Apple sign-in" do
314
+ include StandardId::Testing::ConfigHelpers
315
+
316
+ it "turns on with APPLE_CLIENT_ID" do
317
+ with_provider_env("APPLE_CLIENT_ID" => "com.example.web") do
318
+ expect(StandardId.social_provider_enabled?(:apple)).to be(true)
319
+ end
320
+ end
321
+ end
322
+ ```
323
+
324
+ A field your initializer assigns explicitly ignores ENV, inside the helper too.
325
+
302
326
  ```ruby
303
327
  StandardId.configure do |config|
304
328
  # Only needed when not using the canonical ENV names above:
@@ -518,6 +542,7 @@ Subscribe to the `PASSWORDLESS_CODE_GENERATED` event to deliver OTP codes:
518
542
  ```ruby
519
543
  # config/initializers/standard_id_events.rb
520
544
  StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
545
+ next if event[:skip_sender] # Otp.issue(delivery: :manual) — the caller delivers
521
546
  case event[:channel]
522
547
  when "email"
523
548
  UserMailer.send_code(event[:identifier], event[:code_challenge].code).deliver_now
@@ -527,13 +552,21 @@ StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do
527
552
  end
528
553
  ```
529
554
 
555
+ Set `c.passwordless.delivery = :custom` (the default) so the engine's built-in
556
+ `PasswordlessMailer` stays out of the way; with `:built_in` the engine emails
557
+ the code itself. The subscriber runs synchronously inside the request, so
558
+ `I18n.locale`, `Current.*` and the like are still available.
559
+
530
560
  Event payload includes:
531
561
  - `channel` - `"email"` or `"sms"`
532
562
  - `identifier` - The email address or phone number
533
563
  - `code_challenge` - The code challenge object with `.code` method
534
564
  - `expires_at` - When the code expires
565
+ - `realm` - The OTP realm (`"authentication"` for sign-in)
566
+ - `skip_sender` - `true` for `Otp.issue(delivery: :manual)`; don't deliver
567
+ - `delivery` - The `Otp.issue` delivery mode (`:built_in` / `:custom` / `:manual`), `nil` otherwise
535
568
 
536
- > **Note**: If you're using the deprecated `passwordless_email_sender` or `passwordless_sms_sender` callbacks, see the [Migration Guide](docs/MIGRATION_GUIDE.md) for upgrade instructions.
569
+ > **Note**: `passwordless_email_sender` / `passwordless_sms_sender` were removed in 0.43; see the [Migration Guide](docs/MIGRATION_GUIDE.md).
537
570
 
538
571
  ### Using OTP for non-authentication flows
539
572
 
@@ -575,9 +608,9 @@ end
575
608
 
576
609
  | Mode | Behavior |
577
610
  |-------------|--------------------------------------------------------------------------|
578
- | `:built_in` | Uses the engine's bundled `PasswordlessMailer` (email only). |
579
- | `:custom` | Invokes `passwordless_email_sender` / `passwordless_sms_sender` callback.|
580
- | `:manual` | Skips delivery; returns the raw `code` on the result for caller to deliver. |
611
+ | `:built_in` (default) | Follows `c.passwordless.delivery`: the engine's `PasswordlessMailer` (email only) when that is `:built_in`, otherwise your `PASSWORDLESS_CODE_GENERATED` subscriber. |
612
+ | `:custom` | Your `PASSWORDLESS_CODE_GENERATED` subscriber delivers (payload `delivery: :custom`); the engine mailer never does, even under a global `:built_in`. Nothing is sent unless you subscribe. |
613
+ | `:manual` | Skips delivery (payload `skip_sender: true`); returns the raw `code` on the result for the caller to deliver. |
581
614
 
582
615
  **Realm isolation.** `realm:` is a free-form string that partitions challenges by purpose. A code issued for realm `"widget_contact_verification"` cannot be used to verify against realm `"authentication"` (or any other realm) — even for the same `target`. Choose a stable string per flow.
583
616
 
@@ -795,6 +828,7 @@ module StandardIdSentrySpans
795
828
  def self.start(name, _id, payload)
796
829
  scope = Sentry.get_current_scope
797
830
  parent = scope&.get_span
831
+ # No transaction in progress (a job, a console) → no parent → no span.
798
832
  span = parent&.start_child(
799
833
  op: "standard_id.#{name.delete_suffix('.standard_id')}",
800
834
  description: Array(payload[:audience]).join(", ").presence
@@ -808,7 +842,10 @@ module StandardIdSentrySpans
808
842
  return unless span
809
843
 
810
844
  span.finish
811
- Sentry.get_current_scope.set_span(parent)
845
+ # Restore the parent. Guarded: Scope#set_span raises ArgumentError on nil,
846
+ # and the current scope may have changed since #start.
847
+ scope = Sentry.get_current_scope
848
+ scope.set_span(parent) if scope && parent
812
849
  end
813
850
  end
814
851
 
@@ -1350,7 +1387,8 @@ end
1350
1387
  | `rescue_to_oauth_error(message_prefix = nil) { ... }` | Lets `StandardId::OAuthError` through; wraps anything else in one, keeping `cause` |
1351
1388
 
1352
1389
  A plugin using `env:`, `required:` or these helpers should depend on
1353
- `standard_id >= 0.42`. `Providers::Base.setup` was removed in 0.42 — do
1390
+ `standard_id >= 0.42`. `Providers::Base.setup` is no longer called (removed
1391
+ from the base class in 0.42; the call-with-a-warning shim went in 0.43) — do
1354
1392
  one-off initialization in your own Railtie instead.
1355
1393
 
1356
1394
  **Testing a plugin, or an app that uses one:**
@@ -1482,6 +1520,23 @@ Retention is bounded by the grace windows, not the cadence; each job is a single
1482
1520
  schedule: every hour at minute 13
1483
1521
  ```
1484
1522
 
1523
+ **One entry, one cron monitor (0.43+).** `StandardId::CleanupAllJob` runs all four inline; a failure in one does not skip the rest, and the first error is re-raised afterwards so the run still fails. Schedule it instead of the four when you want a single recurring entry — e.g. to put the schedule under one Sentry cron monitor (the gem's jobs carry none). Subclass it to attach the monitor:
1524
+
1525
+ ```ruby
1526
+ # app/jobs/standard_id_cleanup_job.rb
1527
+ class StandardIdCleanupJob < StandardId::CleanupAllJob
1528
+ include Sentry::Cron::MonitorCheckIns
1529
+ sentry_monitor_check_ins slug: "standard-id-cleanup",
1530
+ monitor_config: Sentry::Cron::MonitorConfig.from_crontab("7 * * * *", checkin_margin: 5, max_runtime: 10)
1531
+ end
1532
+ ```
1533
+
1534
+ ```yaml
1535
+ standard_id_cleanup:
1536
+ class: StandardIdCleanupJob
1537
+ schedule: every hour at minute 7
1538
+ ```
1539
+
1485
1540
  Rake wrappers (`standard_id:cleanup:all`, `:sessions`, `:refresh_tokens`, `:authorization_codes`, `:code_challenges`) run the same jobs inline. See [docs/OPERATIONS.md](docs/OPERATIONS.md) for sidekiq-cron, whenever and system-cron examples.
1486
1541
 
1487
1542
  ## Contributing
@@ -41,29 +41,15 @@ module StandardId
41
41
  end
42
42
  end
43
43
 
44
- # Resolve the effective per-IP login rate limit, preferring the
45
- # mechanism-agnostic `login_per_ip` alias and falling back to the deprecated
46
- # `password_login_per_ip` when the host left the alias at its default (i.e.
47
- # only configured the old name). The new alias wins whenever explicitly set.
48
- # Mirrors the max_attempts -> max_attempts_per_challenge deprecation-alias
49
- # precedent (prefer-new, fall-back-to-old).
44
+ # Effective per-IP login rate limit (`rate_limits.login_per_ip`). The
45
+ # deprecated `password_login_per_ip` fallback was removed in 0.43.
50
46
  def self.login_per_ip
51
- resolve_login_alias(:login_per_ip, :password_login_per_ip)
47
+ StandardId.config.rate_limits.login_per_ip
52
48
  end
53
49
 
54
- # Effective per-email login rate limit; see .login_per_ip.
50
+ # Effective per-email login rate limit (`rate_limits.login_per_email`).
55
51
  def self.login_per_email
56
- resolve_login_alias(:login_per_email, :password_login_per_email)
57
- end
58
-
59
- # Return the alias value unless it still equals its schema default, in which
60
- # case fall back to the deprecated field. Both fields share the same default,
61
- # so the effective default is unchanged when neither is set.
62
- def self.resolve_login_alias(new_field, old_field)
63
- rate_limits = StandardId.config.rate_limits
64
- default = StandardId.config.__schema__.field_for(:rate_limits, new_field).default_value
65
- new_value = rate_limits[new_field]
66
- new_value == default ? rate_limits[old_field] : new_value
52
+ StandardId.config.rate_limits.login_per_email
67
53
  end
68
54
 
69
55
  private
@@ -11,8 +11,7 @@ module StandardId
11
11
  layout "public"
12
12
 
13
13
  # RAR-51: Rate limit login attempts by IP (20 per 15 minutes). Reads the
14
- # mechanism-agnostic `login_per_ip` alias, falling back to the deprecated
15
- # `password_login_per_ip` — this action branches password OR passwordless,
14
+ # mechanism-agnostic `login_per_ip` — this action branches password OR passwordless,
16
15
  # so on a passwordless app this governs the OTP-send limit, not a password
17
16
  # login. See StandardId::RateLimitHandling.login_per_ip.
18
17
  rate_limit to: StandardId::RateLimitHandling.login_per_ip,
@@ -35,17 +35,21 @@ module StandardId
35
35
  render plain: "missing email", status: :unprocessable_content and return
36
36
  end
37
37
 
38
- challenge = StandardId::CodeChallenge.create!(
38
+ # Issued through the passwordless strategy (validation, retry delay,
39
+ # previous-code invalidation) so delivery goes through the
40
+ # PASSWORDLESS_CODE_GENERATED event like every other OTP. Before 0.43
41
+ # this called the since-removed passwordless_email_sender directly.
42
+ result = StandardId::Otp.issue(
39
43
  realm: "verification",
40
- channel: "email",
41
44
  target: email,
42
- code: StandardId::Passwordless.generate_otp_code,
43
- expires_at: 10.minutes.from_now,
44
- ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
45
- user_agent: request.user_agent
45
+ channel: :email,
46
+ request: request,
47
+ expires_in: 10.minutes
46
48
  )
47
-
48
- StandardId.config.passwordless_email_sender&.call(email, challenge.code)
49
+ unless result.success?
50
+ flash[:alert] = result.error_message
51
+ render plain: "invalid email", status: :unprocessable_content and return
52
+ end
49
53
 
50
54
  redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
51
55
  end
@@ -35,17 +35,21 @@ module StandardId
35
35
  render plain: "invalid phone", status: :unprocessable_content and return
36
36
  end
37
37
 
38
- challenge = StandardId::CodeChallenge.create!(
38
+ # Issued through the passwordless strategy (validation, retry delay,
39
+ # previous-code invalidation) so delivery goes through the
40
+ # PASSWORDLESS_CODE_GENERATED event like every other OTP. Before 0.43
41
+ # this called the since-removed passwordless_sms_sender directly.
42
+ result = StandardId::Otp.issue(
39
43
  realm: "verification",
40
- channel: "sms",
41
44
  target: phone,
42
- code: StandardId::Passwordless.generate_otp_code,
43
- expires_at: 10.minutes.from_now,
44
- ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
45
- user_agent: request.user_agent
45
+ channel: :sms,
46
+ request: request,
47
+ expires_in: 10.minutes
46
48
  )
47
-
48
- StandardId.config.passwordless_sms_sender&.call(phone, challenge.code)
49
+ unless result.success?
50
+ flash[:alert] = result.error_message
51
+ render plain: "invalid phone", status: :unprocessable_content and return
52
+ end
49
53
 
50
54
  redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
51
55
  end
@@ -0,0 +1,46 @@
1
+ module StandardId
2
+ # Runs all four expired-row cleanup jobs inline, in one job.
3
+ #
4
+ # Schedule this ONE job instead of the four individual ones when you want a
5
+ # single recurring entry — typically so the schedule carries one cron
6
+ # monitor (Sentry, Honeybadger, …) rather than four. Subclass it in the host
7
+ # to attach the monitor:
8
+ #
9
+ # # app/jobs/standard_id_cleanup_job.rb
10
+ # class StandardIdCleanupJob < StandardId::CleanupAllJob
11
+ # include Sentry::Cron::MonitorCheckIns
12
+ # sentry_monitor_check_ins slug: "standard-id-cleanup",
13
+ # monitor_config: Sentry::Cron::MonitorConfig.from_crontab("7 * * * *")
14
+ # end
15
+ #
16
+ # One table's DELETE failing (a lock timeout, say) does not skip the others;
17
+ # the first error is re-raised once all four have run, so the run still
18
+ # fails its cron check-in and lands in the queue's failed executions.
19
+ #
20
+ # Each job keeps its default grace windows (see README "Scheduled
21
+ # Maintenance"); retention is bounded by those, not by the cadence.
22
+ class CleanupAllJob < ApplicationJob
23
+ queue_as :default
24
+
25
+ # @return [Array<Class>] the cleanup jobs run, in order
26
+ def self.jobs
27
+ [
28
+ StandardId::CleanupExpiredSessionsJob,
29
+ StandardId::CleanupExpiredRefreshTokensJob,
30
+ StandardId::CleanupExpiredAuthorizationCodesJob,
31
+ StandardId::CleanupExpiredCodeChallengesJob
32
+ ]
33
+ end
34
+
35
+ def perform
36
+ errors = self.class.jobs.filter_map do |job|
37
+ job.perform_now
38
+ nil
39
+ rescue StandardError => e
40
+ Rails.logger.warn("[StandardId] #{job.name} failed: #{e.class}: #{e.message}")
41
+ e
42
+ end
43
+ raise errors.first if errors.any?
44
+ end
45
+ end
46
+ end
@@ -253,10 +253,10 @@ StandardId.configure do |c|
253
253
  # Account.create!(email: identifier.value)
254
254
  # }
255
255
 
256
- # c.passwordless_email_sender / c.passwordless_sms_sender are DEPRECATED
257
- # (assigning them emits a StandardId deprecation warning). Deliver codes from
258
- # an event subscriber instead — it runs synchronously in the request, so
259
- # I18n.locale etc. are still available:
256
+ # With c.passwordless.delivery = :custom, deliver codes from an event
257
+ # subscriber (c.passwordless_email_sender / _sms_sender were removed in
258
+ # 0.43). It runs synchronously in the request, so I18n.locale etc. are still
259
+ # available. The same subscriber delivers Otp.issue(delivery: :custom) codes.
260
260
  #
261
261
  # StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
262
262
  # next if event[:skip_sender] # Otp.issue(delivery: :manual)
@@ -510,8 +510,7 @@ StandardId.configure do |c|
510
510
 
511
511
  # Login limits. The login action branches password OR passwordless, so on a
512
512
  # passwordless app these govern the OTP-SEND limit. Prefer the
513
- # mechanism-agnostic names; the old password_login_per_ip/_per_email names
514
- # still work but emit a deprecation warning (the new name wins when both are set).
513
+ # mechanism-agnostic names (password_login_per_ip/_per_email were removed in 0.43).
515
514
  # c.rate_limits.login_per_ip = 20 # per 15 minutes
516
515
  # c.rate_limits.login_per_email = 5 # per 15 minutes
517
516
  # c.rate_limits.otp_verify_per_ip = 20 # per 15 minutes
@@ -9,11 +9,12 @@ 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
- # 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."
12
+ # Removed in 0.43 (deprecated since 0.1.7): assigning either raises
13
+ # StandardId::ConfigurationError with this hint.
14
+ removed :passwordless_email_sender,
15
+ "deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and set c.passwordless.delivery = :custom. See docs/MIGRATION_GUIDE.md."
16
+ removed :passwordless_sms_sender,
17
+ "deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber (skip it when event[:skip_sender] is true). See docs/MIGRATION_GUIDE.md."
17
18
  field :issuer, type: :string, default: nil
18
19
 
19
20
  # Whether `JwtService.decode` REQUIRES a matching `iss` claim.
@@ -72,9 +73,8 @@ StandardId::ConfigSchema.define do
72
73
  # Scope-aware authentication: maps scope names to profile-based access config.
73
74
  # Each scope is a hash with keys: :profile_types (Array<String>), :after_sign_in_path,
74
75
  # :no_profile_message, :label, :allow_registration, :authorizer.
75
- # The legacy :profile_type (singular String) key is still accepted for backward
76
- # compatibility and coerced into a single-element :profile_types array (deprecation
77
- # warning fires on use).
76
+ # The legacy :profile_type (singular) key was removed in 0.43; a scope that still
77
+ # sets it raises StandardId::ConfigurationError at boot.
78
78
  field :scopes, type: :any, default: {}
79
79
 
80
80
  # Callable that resolves the active scope name for a given request/session.
@@ -153,11 +153,8 @@ StandardId::ConfigSchema.define do
153
153
  end
154
154
 
155
155
  scope :passwordless do
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."
156
+ # Removed in 0.43 (no effect since 0.8).
157
+ removed :enabled, "it had no effect since 0.8. Use web.passwordless_login (WebEngine) instead, and delete this line."
161
158
  field :connection, type: :string, default: "email"
162
159
  field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
163
160
 
@@ -312,12 +309,10 @@ StandardId::ConfigSchema.define do
312
309
  # RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS.
313
310
  field :refresh_token_reuse_leeway, type: :integer, default: 0
314
311
  field :token_lifetimes, type: :hash, default: -> { {} }
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."
312
+ # Removed in 0.43 — never read by the gem. OAuth clients are
313
+ # StandardId::ClientApplication rows with ClientSecretCredential secrets.
314
+ removed :client_id, "it was never read — OAuth clients are StandardId::ClientApplication records. Delete this line."
315
+ removed :client_secret, "it was never read — client secrets are StandardId::ClientSecretCredential records. Delete this line."
321
316
  field :scope_claims, type: :hash, default: -> { {} }
322
317
  field :claim_resolvers, type: :hash, default: -> { {} }
323
318
  # List of audience values that tokens issued and accepted by this app may
@@ -601,17 +596,9 @@ StandardId::ConfigSchema.define do
601
596
  field :login_per_ip, type: :integer, default: 20 # per 15 minutes
602
597
  field :login_per_email, type: :integer, default: 5 # per 15 minutes
603
598
 
604
- # Deprecated mechanism-specific names, retained for backwards compatibility.
605
- # The schema raises ConfigurationError on unknown fields at boot, so these
606
- # must NOT be removed while hosts still set them. When a host leaves the
607
- # `login_per_*` alias at its default, the login controller falls back to
608
- # these values (see StandardId::RateLimitHandling.login_per_ip). New name
609
- # wins when explicitly set. Mirrors the max_attempts ->
610
- # max_attempts_per_challenge deprecation-alias precedent.
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."
599
+ # Removed in 0.43: the mechanism-specific names of login_per_ip / login_per_email.
600
+ removed :password_login_per_ip, "use rate_limits.login_per_ip (same meaning; it also governs passwordless OTP sends)."
601
+ removed :password_login_per_email, "use rate_limits.login_per_email (same meaning; it also governs passwordless OTP sends)."
615
602
 
616
603
  # RAR-60: OTP verification
617
604
  field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
@@ -630,7 +617,7 @@ StandardId::ConfigSchema.define do
630
617
  field :signup_per_ip, type: :integer, default: 10 # per hour
631
618
 
632
619
  # API OTP-initiation limits — the API counterpart of the web *login* limits
633
- # (login_per_* / the deprecated password_login_*): the API passwordless
620
+ # (login_per_*): the API passwordless
634
621
  # `start` action and the web login action both drive the passwordless
635
622
  # `start!` strategy. NOT the counterpart of verification_start_*
636
623
  # (email/phone verification), which bypasses the strategy via a direct
@@ -28,7 +28,11 @@ module StandardId
28
28
  def build = instance.apply(Config.new)
29
29
  end
30
30
 
31
- def initialize = @scopes = Concurrent::Map.new
31
+ def initialize
32
+ @scopes = Concurrent::Map.new
33
+ @removed = Concurrent::Map.new
34
+ end
35
+
32
36
  def scopes = @scopes
33
37
  def scope?(name) = @scopes.key?(name.to_sym)
34
38
  def field?(scope_name, field_name) = !!@scopes[scope_name.to_sym]&.key?(field_name.to_sym)
@@ -44,6 +48,18 @@ module StandardId
44
48
  fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default, deprecated) }
45
49
  end
46
50
 
51
+ # Record a field that has been REMOVED from the schema. Assigning it raises
52
+ # StandardId::ConfigurationError carrying +message+ (what to do instead),
53
+ # rather than the generic "Unknown field" — or, for a base-scope field
54
+ # assigned through the top-level config, rather than being silently
55
+ # stored on the top-level OrderedOptions and ignored.
56
+ def add_removed_field(scope:, name:, message:)
57
+ @removed[[scope.to_sym, name.to_sym]] = message
58
+ end
59
+
60
+ # @return [String, nil] the removal hint for a removed field, else nil
61
+ def removed_field_message(scope_name, field_name) = @removed[[scope_name.to_sym, field_name.to_sym]]
62
+
47
63
  # Register a scope without adding a field. Allows `define { scope :foo }` so
48
64
  # provider gems can later `add_field(scope: :foo, ...)` against an existing scope.
49
65
  def ensure_scope(name)
@@ -107,6 +123,10 @@ module StandardId
107
123
  def field(name, type: :string, default: nil, deprecated: nil, **)
108
124
  @schema.add_field(scope: @scope_name, name: name, type: type, default: default, deprecated: deprecated)
109
125
  end
126
+
127
+ def removed(name, message)
128
+ @schema.add_removed_field(scope: @scope_name, name: name, message: message)
129
+ end
110
130
  end
111
131
 
112
132
  # Per-scope OrderedOptions. Validates writes, casts and dups Array/Hash values
@@ -127,9 +147,45 @@ module StandardId
127
147
  def []=(key, value)
128
148
  validate!(key)
129
149
  warn_if_deprecated(key, value)
150
+ assigned_keys << key.to_sym
130
151
  super(key.to_sym, value)
131
152
  end
132
153
 
154
+ # Whether the host explicitly assigned +key+ (even to nil).
155
+ #
156
+ # Unlike #key?, which is also true for every field whose schema default
157
+ # was written when the config was built (so it is true for a provider
158
+ # field the host never touched), this is only true after an assignment.
159
+ #
160
+ # @param key [Symbol, String]
161
+ # @return [Boolean]
162
+ def assigned?(key) = assigned_keys.include?(key.to_sym)
163
+
164
+ # Deleting a key also forgets that it was assigned, so reads fall back
165
+ # to the (live) schema default again.
166
+ def delete(key)
167
+ assigned_keys.delete(key.to_sym)
168
+ super(key.to_sym)
169
+ end
170
+
171
+ # Re-evaluate the schema default of every field the host never assigned.
172
+ #
173
+ # Defaults are resolved once, when the config is built — including the
174
+ # ENV fallback of provider fields (`GOOGLE_CLIENT_ID` etc.). Call this
175
+ # after changing ENV to make unassigned fields pick the new value up;
176
+ # explicitly assigned fields are left alone. Meant for tests — see
177
+ # StandardId::Testing.with_provider_env.
178
+ #
179
+ # @return [self]
180
+ def refresh_defaults!
181
+ @schema.scopes[@scope_name]&.each_value do |field|
182
+ next if assigned?(field.name)
183
+
184
+ RAW_SET.bind_call(self, field.name, field.default_value)
185
+ end
186
+ self
187
+ end
188
+
133
189
  def [](key)
134
190
  sym = key.to_sym
135
191
  validate!(sym) unless key?(sym) || resolver
@@ -153,8 +209,14 @@ module StandardId
153
209
 
154
210
  private
155
211
 
212
+ def assigned_keys = (@assigned_keys ||= Set.new)
213
+
156
214
  def validate!(key)
157
215
  return if @schema.field?(@scope_name, key)
216
+ if (message = @schema.removed_field_message(@scope_name, key))
217
+ raise StandardId::ConfigurationError,
218
+ "StandardId.config.#{config_path(key)} was removed in StandardId 0.43: #{message}"
219
+ end
158
220
  raise StandardId::ConfigurationError,
159
221
  "Unknown field '#{key}' for scope '#{@scope_name}'. Valid fields: #{@schema.scopes[@scope_name]&.keys}"
160
222
  end
@@ -215,6 +277,9 @@ module StandardId
215
277
  sym = key.to_sym
216
278
  if __schema__ && !__schema__.scope?(sym) && !key?(sym) && (target = unique_scope_for(sym))
217
279
  self[target][sym] = value
280
+ elsif __schema__ && (message = __schema__.removed_field_message(:base, sym))
281
+ raise StandardId::ConfigurationError,
282
+ "StandardId.config.#{sym} was removed in StandardId 0.43: #{message}"
218
283
  else
219
284
  super(sym, value)
220
285
  end
@@ -97,6 +97,7 @@ module StandardId
97
97
  # surfaces typos at boot instead of at callback time in production.
98
98
  StandardId::Config::CallableValidator.validate!
99
99
  StandardId::Config::ScopeClaimsValidator.validate!
100
+ StandardId::ScopeConfig.validate_all!
100
101
 
101
102
  StandardId::Engine.verify_host_cookie_encryption!(app)
102
103
  StandardId::MigrationCheck.verify_at_boot!
@@ -11,6 +11,9 @@ module StandardId
11
11
  # challenges, etc.) and would otherwise receive a duplicate email
12
12
  # from this subscriber when c.passwordless.delivery == :built_in.
13
13
  return if event[:skip_sender]
14
+ # Otp.issue(delivery: :custom): the host's own subscriber delivers
15
+ # this code, even where the engine mailer is the global default.
16
+ return if event[:delivery]&.to_sym == :custom
14
17
  return unless built_in_delivery?
15
18
  return unless event[:channel] == "email"
16
19
 
@@ -44,15 +44,24 @@ module StandardId
44
44
  #
45
45
  # == Delivery modes
46
46
  #
47
- # * +:built_in+ — uses the engine's bundled mailer
48
- # (+StandardId::PasswordlessMailer+) when
49
- # +StandardId.config.passwordless.delivery+ is +:built_in+. Works for
50
- # +channel: :email+ only.
51
- # * +:custom+ — calls the configured +passwordless_email_sender+ or
52
- # +passwordless_sms_sender+ callback.
53
- # * +:manual+ — skip delivery entirely; the raw +code+ is returned on the
54
- # result so the caller can deliver it however they like. Useful for
55
- # custom widget/embedded flows that want full control over the channel.
47
+ # Every mode publishes +StandardId::Events::PASSWORDLESS_CODE_GENERATED+
48
+ # (synchronously, inside the call); the payload carries +delivery:+ and
49
+ # +skip_sender:+ so subscribers can tell the modes apart.
50
+ #
51
+ # * +:built_in+ (default) — follow the global
52
+ # +StandardId.config.passwordless.delivery+: when that is +:built_in+ the
53
+ # engine's +PasswordlessMailer+ sends the code (+channel: :email+ only);
54
+ # otherwise the host's own +PASSWORDLESS_CODE_GENERATED+ subscriber does.
55
+ # * +:custom+ — the host's +PASSWORDLESS_CODE_GENERATED+ subscriber delivers
56
+ # this code; the engine mailer never does, even when the global setting is
57
+ # +:built_in+. Use it for a realm whose message differs from the sign-in
58
+ # email. Nothing is sent unless the host subscribes. (Before 0.43 this
59
+ # called +passwordless_email_sender+ / +passwordless_sms_sender+, both
60
+ # removed in 0.43.)
61
+ # * +:manual+ — no subscriber should deliver (+skip_sender: true+); the raw
62
+ # +code+ is returned on the result so the caller can deliver it however
63
+ # they like. Useful for custom widget/embedded flows that want full
64
+ # control over the channel.
56
65
  module Otp
57
66
  VALID_CHANNELS = %w[email sms].freeze
58
67
  VALID_DELIVERIES = %i[built_in custom manual].freeze
@@ -112,13 +121,6 @@ module StandardId
112
121
  return failure_issue_result(:invalid_request, "target: is required")
113
122
  end
114
123
 
115
- # Fail loud when the caller asked for :custom delivery but has not
116
- # configured the corresponding sender callback. Without this guard
117
- # BaseStrategy#start! would silently skip delivery (the sender_callback
118
- # is nil and `&.call` no-ops) while Otp.issue still returned a success
119
- # result — making host apps believe the OTP was sent when it was not.
120
- assert_custom_sender_configured!(channel_s) if delivery_sym == :custom
121
-
122
124
  strategy = build_strategy(channel_s, request, realm: realm_s)
123
125
 
124
126
  begin
@@ -127,7 +129,8 @@ module StandardId
127
129
  code_length: code_length,
128
130
  expires_in: normalize_expires_in(expires_in),
129
131
  metadata: metadata,
130
- skip_sender: delivery_sym == :manual
132
+ skip_sender: delivery_sym == :manual,
133
+ delivery: delivery_sym
131
134
  )
132
135
  rescue StandardId::InvalidRequestError => e
133
136
  # Validation failures from the strategy (invalid email/phone format,
@@ -226,17 +229,6 @@ module StandardId
226
229
  error_message: error_message
227
230
  )
228
231
  end
229
-
230
- def assert_custom_sender_configured!(channel)
231
- attr = channel == "email" ? :passwordless_email_sender : :passwordless_sms_sender
232
- sender = StandardId.config.public_send(attr)
233
- return if sender.respond_to?(:call)
234
-
235
- raise StandardId::ConfigurationError,
236
- "Otp.issue(delivery: :custom) requires StandardId.config.#{attr} to be a callable " \
237
- "(got #{sender.inspect}). Configure it in an initializer, or use delivery: :built_in " \
238
- "to let the engine's event subscriber deliver, or delivery: :manual to receive the raw code."
239
- end
240
232
  end
241
233
 
242
234
  # Minimal stand-in used when no request is available (e.g. jobs, tests).
@@ -12,14 +12,23 @@ module StandardId
12
12
  raise NotImplementedError
13
13
  end
14
14
 
15
- # Start flow: validate recipient, create challenge, and trigger sender
16
- # attrs: { connection:, username:, code_length:, expires_in:, metadata:, skip_sender: }
15
+ # Start flow: validate recipient, create challenge, and publish
16
+ # PASSWORDLESS_CODE_GENERATED — delivery happens in a subscriber to that
17
+ # event (the engine's PasswordlessDeliverySubscriber when
18
+ # c.passwordless.delivery == :built_in, otherwise the host's own).
19
+ #
20
+ # attrs: { connection:, username:, code_length:, expires_in:, metadata:, skip_sender:, delivery: }
21
+ #
22
+ # +delivery+ (:built_in / :custom / :manual, or nil when not called via
23
+ # StandardId::Otp.issue) is forwarded in the event payload; see
24
+ # Otp.issue for what each value means to the subscribers.
17
25
  def start!(attrs)
18
26
  username = attrs[:username]
19
27
  code_length = attrs[:code_length]
20
28
  expires_in = attrs[:expires_in]
21
29
  metadata = attrs[:metadata] || {}
22
30
  skip_sender = attrs[:skip_sender] == true
31
+ delivery = attrs[:delivery]&.to_sym
23
32
 
24
33
  validate_username!(username)
25
34
  run_username_validator!(username)
@@ -33,11 +42,10 @@ module StandardId
33
42
  )
34
43
  # skip_sender is forwarded into the event payload so subscribers that
35
44
  # deliver on PASSWORDLESS_CODE_GENERATED (e.g. PasswordlessDeliverySubscriber)
36
- # can honor a per-call manual-delivery request — not just the legacy
37
- # sender_callback. Without this, Otp.issue(delivery: :manual) silently
38
- # double-delivers when c.passwordless.delivery == :built_in.
39
- emit_code_generated(challenge, username, skip_sender: skip_sender)
40
- sender_callback&.call(username, challenge.code) unless skip_sender
45
+ # can honor a per-call manual-delivery request. Without this,
46
+ # Otp.issue(delivery: :manual) silently double-delivers when
47
+ # c.passwordless.delivery == :built_in.
48
+ emit_code_generated(challenge, username, skip_sender: skip_sender, delivery: delivery)
41
49
  emit_code_sent(username) unless skip_sender
42
50
  challenge
43
51
  end
@@ -156,11 +164,6 @@ module StandardId
156
164
  raise NotImplementedError
157
165
  end
158
166
 
159
- def sender_callback
160
- # Implement in subclasses
161
- nil
162
- end
163
-
164
167
  private
165
168
 
166
169
  # Extract request parameters safely. Returns an empty hash if the request
@@ -187,7 +190,7 @@ module StandardId
187
190
  )
188
191
  end
189
192
 
190
- def emit_code_generated(challenge, username, skip_sender: false)
193
+ def emit_code_generated(challenge, username, skip_sender: false, delivery: nil)
191
194
  StandardId::Events.publish(
192
195
  StandardId::Events::PASSWORDLESS_CODE_GENERATED,
193
196
  code_challenge: challenge,
@@ -195,7 +198,8 @@ module StandardId
195
198
  channel: connection_type,
196
199
  realm: @realm,
197
200
  expires_at: challenge.expires_at,
198
- skip_sender: skip_sender
201
+ skip_sender: skip_sender,
202
+ delivery: delivery
199
203
  )
200
204
  end
201
205
 
@@ -20,11 +20,6 @@ module StandardId
20
20
  identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: normalized)
21
21
  identifier&.account
22
22
  end
23
-
24
- def sender_callback
25
- return nil if StandardId.config.passwordless.delivery == :built_in
26
- StandardId.config.passwordless_email_sender
27
- end
28
23
  end
29
24
  end
30
25
  end
@@ -25,10 +25,6 @@ module StandardId
25
25
  identifier = StandardId::PhoneNumberIdentifier.includes(:account).find_by(value: phone_number)
26
26
  identifier&.account
27
27
  end
28
-
29
- def sender_callback
30
- StandardId.config.passwordless_sms_sender
31
- end
32
28
  end
33
29
  end
34
30
  end
@@ -9,11 +9,6 @@ module StandardId
9
9
  # StandardId (see Providers::Base) rather than to ConfigSchema.
10
10
  PROVIDER_FIELD_OPTIONS = %i[env required].freeze
11
11
 
12
- # The gem-wide StandardId.deprecator (registered in
13
- # Rails.application.deprecators), kept as a constant for existing callers.
14
- # The Base.setup message names its own removal version (1.0).
15
- DEPRECATOR = StandardId.deprecator
16
-
17
12
  @providers = Concurrent::Map.new
18
13
 
19
14
  class << self
@@ -28,7 +23,6 @@ module StandardId
28
23
  validate_provider!(provider_class)
29
24
  providers[name.to_s] = provider_class
30
25
  declare_config_schema(provider_class)
31
- run_deprecated_setup(provider_class)
32
26
  provider_class
33
27
  end
34
28
 
@@ -53,9 +47,8 @@ module StandardId
53
47
  # populated before any initializer runs.
54
48
  #
55
49
  # Only FIELD DECLARATION moves earlier. Full `register` — which also runs
56
- # `validate_provider!` and the provider's `setup` — deliberately stays in
57
- # `after_initialize`, where the host's configuration is complete and
58
- # `setup` can rely on it.
50
+ # `validate_provider!` — deliberately stays in `after_initialize`, where
51
+ # the host's configuration is complete.
59
52
  #
60
53
  # Idempotent: `ConfigSchema#add_field` uses `compute_if_absent`, so a field
61
54
  # already declared here is untouched when the plugin later calls `register`.
@@ -207,20 +200,6 @@ module StandardId
207
200
  end
208
201
  end
209
202
 
210
- # Providers::Base.setup was removed in 0.42: no known plugin overrode
211
- # it, and register — its only caller — runs from after_initialize, where
212
- # a plugin's own Railtie can do the same work. A provider that still
213
- # defines `setup` keeps working, with a deprecation warning.
214
- def run_deprecated_setup(provider_class)
215
- return unless provider_class.respond_to?(:setup)
216
-
217
- DEPRECATOR.warn(
218
- "#{provider_class.name || provider_class}.setup is deprecated and will not be called " \
219
- "by StandardId 1.0. Move provider initialization into the plugin's Railtie."
220
- )
221
- provider_class.setup
222
- end
223
-
224
203
  def production?
225
204
  defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
226
205
  end
@@ -24,35 +24,35 @@ module StandardId
24
24
  :allow_registration,
25
25
  :authorizer
26
26
 
27
- # Kept as an alias for hosts/specs that reference it; it is the gem-wide
28
- # StandardId.deprecator, registered in Rails.application.deprecators.
29
- DEPRECATOR = StandardId.deprecator
30
-
31
27
  # Normalize profile-type inputs from config.
32
28
  #
33
- # Accepts:
34
- # - :profile_types (plural) — array of strings (preferred).
35
- # - :profile_type (singular) — single string, retained for back-compat. Emits a
36
- # deprecation warning when present.
29
+ # Accepts :profile_types — an Array of profile-type class names (a single
30
+ # String is wrapped). The singular :profile_type key was removed in 0.43
31
+ # (deprecated in 0.42) and now raises: silently ignoring it would leave the
32
+ # scope with NO profile requirement, admitting every account.
37
33
  #
38
- # Returns an Array<String> (possibly empty).
34
+ # @return [Array<String>] possibly empty
35
+ # @raise [StandardId::ConfigurationError] when the removed :profile_type key is present
39
36
  def self.extract_profile_types(config)
40
- plural = config[:profile_types]
41
- singular = config[:profile_type]
42
-
43
- if singular && plural
44
- raise ArgumentError, "Scope config cannot set both :profile_type and :profile_types — use :profile_types"
37
+ if config.key?(:profile_type) || config.key?("profile_type")
38
+ raise StandardId::ConfigurationError,
39
+ "StandardId scope config key :profile_type was removed in StandardId 0.43. " \
40
+ "Use profile_types: [...] (an Array of profile-type class names) instead."
45
41
  end
46
42
 
47
- if singular
48
- DEPRECATOR.warn(
49
- "StandardId scope config key :profile_type is deprecated and will be removed in v2.0. " \
50
- "Use :profile_types (an Array of profile-type strings) instead."
51
- )
52
- return Array(singular).map(&:to_s).reject(&:blank?)
53
- end
43
+ Array(config[:profile_types]).map(&:to_s).reject(&:blank?)
44
+ end
45
+
46
+ # Build every scope in StandardId.config.scopes once, so a scope config the
47
+ # gem can no longer read (e.g. the removed :profile_type key) fails at boot
48
+ # rather than on the first sign-in under that scope. Run by the engine.
49
+ #
50
+ # @return [void]
51
+ # @raise [StandardId::ConfigurationError]
52
+ def self.validate_all!(scopes = StandardId.config.scopes)
53
+ return if scopes.blank?
54
54
 
55
- Array(plural).map(&:to_s).reject(&:blank?)
55
+ scopes.each { |name, scope_hash| new(name, scope_hash || {}) }
56
56
  end
57
57
 
58
58
  def initialize(name, config = {})
@@ -0,0 +1,55 @@
1
+ module StandardId
2
+ module Testing
3
+ # Helpers for specs that exercise StandardId configuration defaults —
4
+ # chiefly the ENV fallback of provider fields (`GOOGLE_CLIENT_ID`,
5
+ # `APPLE_CLIENT_ID`, …).
6
+ #
7
+ # Those defaults are resolved ONCE, when the config is built at boot, so
8
+ # setting ENV inside a spec changes nothing on its own. `with_provider_env`
9
+ # sets the variables, re-resolves every field the host never assigned, runs
10
+ # the block, then restores both:
11
+ #
12
+ # RSpec.describe "Apple sign-in" do
13
+ # include StandardId::Testing::ConfigHelpers
14
+ #
15
+ # it "is enabled once APPLE_CLIENT_ID is set" do
16
+ # with_provider_env("APPLE_CLIENT_ID" => "com.example.web") do
17
+ # expect(StandardId.social_provider_enabled?(:apple)).to be(true)
18
+ # end
19
+ # end
20
+ # end
21
+ #
22
+ # (also callable as `StandardId::Testing.with_provider_env(...)`).
23
+ #
24
+ # A field the host assigned explicitly — even to nil — ignores ENV by
25
+ # design; `StandardId.config.social.assigned?(:apple_client_id)` tells the
26
+ # two cases apart (`key?` is true for every declared field, assigned or
27
+ # not).
28
+ module ConfigHelpers
29
+ # @param env [Hash{String => String, nil}] variables to set; nil unsets.
30
+ # Usually passed brace-less (`with_provider_env("APPLE_CLIENT_ID" => "x")`),
31
+ # which Ruby delivers as keywords — hence **vars.
32
+ # @param scope [Symbol] config scope whose defaults to re-resolve
33
+ # @yield with the variables set and the scope's defaults re-resolved
34
+ # @return [Object] the block's value
35
+ def with_provider_env(env = {}, scope: :social, **vars)
36
+ env = env.merge(vars)
37
+ previous = env.to_h { |name, _| [name.to_s, ENV[name.to_s]] }
38
+ env.each { |name, value| write_env(name.to_s, value) }
39
+ StandardId.config[scope].refresh_defaults!
40
+ yield
41
+ ensure
42
+ previous&.each { |name, value| write_env(name, value) }
43
+ StandardId.config[scope].refresh_defaults!
44
+ end
45
+
46
+ private
47
+
48
+ def write_env(name, value)
49
+ value.nil? ? ENV.delete(name) : ENV[name] = value.to_s
50
+ end
51
+ end
52
+
53
+ extend ConfigHelpers
54
+ end
55
+ end
@@ -60,7 +60,7 @@ module StandardId
60
60
  # fallback keeps working for later examples.
61
61
  def round_trip(field, value)
62
62
  social = StandardId.config.social
63
- assigned = social.key?(field.to_sym)
63
+ assigned = social.assigned?(field)
64
64
  original = social.to_h[field.to_sym]
65
65
  social.public_send(:"#{field}=", value)
66
66
  social.public_send(field)
@@ -1,5 +1,6 @@
1
1
  require "standard_id/testing/authentication_helpers"
2
2
  require "standard_id/testing/request_helpers"
3
+ require "standard_id/testing/config_helpers"
3
4
  require "standard_id/testing/provider_examples" if defined?(RSpec)
4
5
 
5
6
  module StandardId
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.42.0"
2
+ VERSION = "0.43.0"
3
3
  end
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.42.0
4
+ version: 0.43.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -166,6 +166,7 @@ files:
166
166
  - app/forms/standard_id/web/signup_form.rb
167
167
  - app/helpers/standard_id/application_helper.rb
168
168
  - app/jobs/standard_id/application_job.rb
169
+ - app/jobs/standard_id/cleanup_all_job.rb
169
170
  - app/jobs/standard_id/cleanup_expired_authorization_codes_job.rb
170
171
  - app/jobs/standard_id/cleanup_expired_code_challenges_job.rb
171
172
  - app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb
@@ -303,6 +304,7 @@ files:
303
304
  - lib/standard_id/session_type_resolver.rb
304
305
  - lib/standard_id/testing.rb
305
306
  - lib/standard_id/testing/authentication_helpers.rb
307
+ - lib/standard_id/testing/config_helpers.rb
306
308
  - lib/standard_id/testing/factories/credentials.rb
307
309
  - lib/standard_id/testing/factories/identifiers.rb
308
310
  - lib/standard_id/testing/factories/oauth.rb