parse-stack-next 5.2.1 → 5.4.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +616 -0
  5. data/Gemfile +7 -0
  6. data/Gemfile.lock +12 -4
  7. data/README.md +296 -3
  8. data/Rakefile +243 -41
  9. data/docs/atlas_vector_search_guide.md +86 -2
  10. data/docs/client_sdk_guide.md +38 -0
  11. data/docs/mcp_guide.md +119 -4
  12. data/docs/mongodb_direct_guide.md +93 -1
  13. data/docs/usage_guide.md +11 -1
  14. data/docs/webhooks_guide.md +418 -0
  15. data/examples/README.md +46 -0
  16. data/examples/basic_client.rb +93 -0
  17. data/examples/basic_server.rb +109 -0
  18. data/examples/live_query_listener.rb +98 -0
  19. data/examples/rag_chatbot.rb +221 -0
  20. data/examples/webhook_server.rb +111 -0
  21. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  22. data/lib/parse/agent/tools.rb +45 -5
  23. data/lib/parse/api/aggregate.rb +7 -1
  24. data/lib/parse/api/cloud_functions.rb +12 -4
  25. data/lib/parse/api/hooks.rb +46 -9
  26. data/lib/parse/api/objects.rb +16 -2
  27. data/lib/parse/api/path_segment.rb +33 -0
  28. data/lib/parse/api/server.rb +94 -0
  29. data/lib/parse/api/users.rb +58 -2
  30. data/lib/parse/atlas_search.rb +7 -7
  31. data/lib/parse/client/body_builder.rb +5 -0
  32. data/lib/parse/client/protocol.rb +4 -0
  33. data/lib/parse/client.rb +174 -9
  34. data/lib/parse/embeddings/spend_cap.rb +255 -0
  35. data/lib/parse/embeddings.rb +1 -0
  36. data/lib/parse/live_query/client.rb +3 -1
  37. data/lib/parse/live_query/subscription.rb +32 -5
  38. data/lib/parse/model/acl.rb +4 -2
  39. data/lib/parse/model/associations/belongs_to.rb +47 -0
  40. data/lib/parse/model/classes/audience.rb +52 -4
  41. data/lib/parse/model/classes/user.rb +200 -3
  42. data/lib/parse/model/core/embed_managed.rb +113 -0
  43. data/lib/parse/model/core/pluralized_aliases.rb +30 -0
  44. data/lib/parse/model/core/properties.rb +27 -0
  45. data/lib/parse/model/core/querying.rb +73 -1
  46. data/lib/parse/model/core/vector_searchable.rb +161 -0
  47. data/lib/parse/model/file.rb +35 -2
  48. data/lib/parse/model/object.rb +28 -5
  49. data/lib/parse/mongodb.rb +7 -1
  50. data/lib/parse/pipeline_security.rb +5 -3
  51. data/lib/parse/query/constraints.rb +29 -0
  52. data/lib/parse/query.rb +265 -27
  53. data/lib/parse/retrieval/agent_tool.rb +49 -0
  54. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  55. data/lib/parse/retrieval/reranker.rb +157 -0
  56. data/lib/parse/retrieval/retriever.rb +110 -23
  57. data/lib/parse/stack/version.rb +1 -1
  58. data/lib/parse/stack.rb +173 -1
  59. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  60. data/lib/parse/vector_search/hybrid.rb +578 -0
  61. data/lib/parse/webhooks/payload.rb +399 -11
  62. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  63. data/lib/parse/webhooks.rb +215 -3
  64. data/scripts/docker/Dockerfile.parse +5 -1
  65. data/scripts/docker/docker-compose.test.yml +31 -0
  66. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  67. data/scripts/docker/preflight.sh +76 -0
  68. data/scripts/start-parse.sh +52 -4
  69. metadata +16 -1
data/lib/parse/stack.rb CHANGED
@@ -548,6 +548,21 @@ module Parse
548
548
  # PARSE_STRICT_POINTER_SHAPES=true
549
549
  @strict_pointer_shapes = ENV["PARSE_STRICT_POINTER_SHAPES"] == "true"
550
550
 
551
+ # Configuration for automatic pluralized class-name aliases. When enabled
552
+ # (the default), referencing the plural form of a {Parse::Object} subclass
553
+ # constant resolves to that class, so `Posts.where(...)` works for a class
554
+ # `Post`. The alias is created lazily on first reference via `const_missing`
555
+ # and points at the same class object, so every class method
556
+ # (`where`, `query`, `count`, `find`, `all`, scopes) works for free and
557
+ # `Posts.parse_class` still returns `"Post"`. Classes whose name already
558
+ # ends in `s` are skipped. Set to false to opt out globally.
559
+ # @example Opt out globally
560
+ # Parse.pluralized_aliases = false
561
+ # @example ENV opt-out
562
+ # PARSE_PLURALIZED_ALIASES=false
563
+ # @see Parse::Core::Querying#pluralized_alias!
564
+ @pluralized_aliases = ENV["PARSE_PLURALIZED_ALIASES"] != "false"
565
+
551
566
  # Tuning bundle for the synchronize-create lock. Per-call kwargs override.
552
567
  # Keys: :ttl (seconds, default 3, max 30), :wait (seconds, default 2.0,
553
568
  # max 30), :on_degraded (:warn, :warn_throttled, :raise, :proceed).
@@ -630,7 +645,8 @@ module Parse
630
645
  :rewrite_lookups, :strict_property_redefinition,
631
646
  :synchronize_create_default, :synchronize_create_options, :synchronize_create_secret,
632
647
  :synchronize_create_store, :synchronize_classes,
633
- :strict_pointer_shapes, :suppress_server_version_warning
648
+ :strict_pointer_shapes, :suppress_server_version_warning,
649
+ :pluralized_aliases
634
650
 
635
651
  # Check whether the Parse Server version deprecation warning is
636
652
  # silenced. Returns true if either the in-process accessor or the
@@ -714,6 +730,140 @@ module Parse
714
730
  @strict_pointer_shapes == true
715
731
  end
716
732
 
733
+ # Whether automatic pluralized class-name aliases are enabled. Defaults
734
+ # to true; opt out with `Parse.pluralized_aliases = false` or
735
+ # `PARSE_PLURALIZED_ALIASES=false`. See {Parse.pluralized_aliases}.
736
+ # @return [Boolean]
737
+ def pluralized_aliases?
738
+ @pluralized_aliases != false
739
+ end
740
+
741
+ # @!visibility private
742
+ # Resolve a (possibly plural) missing constant to its singular
743
+ # {Parse::Object} subclass and install the alias on the referencing
744
+ # module. Returns the class when an alias was created, otherwise nil so
745
+ # the caller (`const_missing`) can fall through to `super` and preserve
746
+ # normal `NameError` / autoloading behavior.
747
+ #
748
+ # Guards (fail-through to nil unless ALL hold):
749
+ # - the feature is enabled,
750
+ # - {Parse::Object} is loaded,
751
+ # - the name singularizes to a *different* string (i.e. looks plural),
752
+ # - the singular form does NOT already end in `s` (per design: classes
753
+ # whose name ends in `s` are not auto-aliased),
754
+ # - the singular constant is defined (searching ancestors so a
755
+ # top-level model is visible from a nested reference) and is a
756
+ # `Parse::Object` subclass,
757
+ # - the plural is not already defined on the referencing module.
758
+ #
759
+ # @param mod [Module] the module/class on which `const_missing` fired.
760
+ # @param name [Symbol] the missing constant name.
761
+ # @return [Class, nil]
762
+ def __pluralized_alias_for(mod, name)
763
+ return nil unless pluralized_aliases?
764
+ return nil unless defined?(Parse::Object)
765
+ str = name.to_s
766
+ singular = str.singularize
767
+ return nil if singular == str
768
+ return nil if singular.end_with?("s")
769
+ sym = singular.to_sym
770
+ return nil unless mod.const_defined?(sym, true)
771
+ klass = mod.const_get(sym)
772
+ return nil unless klass.is_a?(Class) && klass < Parse::Object
773
+ return nil if mod.const_defined?(name, false)
774
+ mod.const_set(name, klass)
775
+ klass
776
+ rescue NameError, LoadError
777
+ # const_get/const_defined? can raise on malformed names or autoload
778
+ # failures; never let alias resolution mask the original lookup.
779
+ nil
780
+ end
781
+
782
+ # Verify that every association target across the loaded {Parse::Object}
783
+ # subclasses resolves to a known Parse class. Covers `belongs_to` and
784
+ # `property … as:` pointer targets (via each class's `references`),
785
+ # `has_many … through: :relation` targets (via `relations`), and the
786
+ # query- and array-backed `has_many` targets (via `has_many_associations`)
787
+ # — the bucket where an `as:` typo otherwise stays latent until the
788
+ # association is first traversed at call time.
789
+ #
790
+ # This is the deferred companion to the definition-time scalar guard in
791
+ # {Parse::Associations::BelongsTo::ClassMethods#belongs_to}: at declaration
792
+ # time a forward reference (a target class that is required later) is legal
793
+ # and indistinguishable from a typo, so the cross-class resolution check is
794
+ # run here — after all models are loaded. Intended to run once at boot, in
795
+ # CI, or from a rake task ("during the upgrade").
796
+ #
797
+ # A target resolves when it is a Parse system class (`_User`, `_Role`,
798
+ # `_Installation`, `_Session`, …) or a registered {Parse::Object} subclass
799
+ # (via {Parse::Model.find_class}). Note this checks against *loaded Ruby
800
+ # models*: if you intentionally point at a server-side class that has no
801
+ # Ruby model, define a stub model for it or exclude it via `classes:`.
802
+ #
803
+ # @param classes [Array<Class>, nil] optional subset of Parse::Object
804
+ # subclasses to check; defaults to every loaded subclass.
805
+ # @raise [ArgumentError] if any target is unresolved, listing each
806
+ # offending `Class#field -> 'Target'`.
807
+ # @return [true] when every association target resolves.
808
+ def validate_associations!(classes: nil)
809
+ models = classes || Parse::Object.descendants
810
+ problems = []
811
+ models.each do |klass|
812
+ next unless klass.respond_to?(:parse_class)
813
+ if klass.respond_to?(:references)
814
+ klass.references.each do |field, target|
815
+ next if _association_target_resolvable?(target)
816
+ # `references` is keyed by the remote (camelCase) column; report the
817
+ # declared Ruby accessor so the operator can find the offending line.
818
+ accessor = (klass.respond_to?(:field_map) && klass.field_map.key(field)) || field
819
+ problems << "#{klass}##{accessor} -> #{target.inspect} (no such Parse class)"
820
+ end
821
+ end
822
+ if klass.respond_to?(:relations)
823
+ klass.relations.each do |field, target|
824
+ next if _association_target_resolvable?(target)
825
+ problems << "#{klass}##{field} (relation) -> #{target.inspect} (no such Parse class)"
826
+ end
827
+ end
828
+ if klass.respond_to?(:has_many_associations)
829
+ klass.has_many_associations.each do |accessor, meta|
830
+ # `:relation`-storage has_many is mirrored into `relations` and is
831
+ # already reported above; only the `:query` and `:array` storage
832
+ # targets (which live nowhere else) need checking here. This is the
833
+ # branch where a `has_many … as:` typo hides, since a query-backed
834
+ # has_many resolves its target lazily at call time.
835
+ next if meta[:storage] == :relation
836
+ target = meta[:target_class]
837
+ next if target.nil? || _association_target_resolvable?(target)
838
+ problems << "#{klass}##{accessor} (has_many #{meta[:storage]}) -> " \
839
+ "#{target.inspect} (no such Parse class)"
840
+ end
841
+ end
842
+ end
843
+ unless problems.empty?
844
+ raise ArgumentError,
845
+ "Unresolved Parse association targets:\n " + problems.join("\n ") +
846
+ "\nRequire/define the target class, or fix the `as:`/`class_name:` name."
847
+ end
848
+ true
849
+ end
850
+
851
+ # @!visibility private
852
+ # Whether an association target class name resolves to a known Parse
853
+ # class. Parse system classes resolve against {Parse::Model::SYSTEM_CLASS_MAP}
854
+ # — both the canonical `_`-prefixed value (`_User`) and the bare-name key
855
+ # (`User`) — even when their Ruby class is not loaded; everything else must
856
+ # resolve via {Parse::Model.find_class}. A leading underscore is NOT a
857
+ # blanket pass: a typo'd system name such as `_Usr` is neither in the map
858
+ # nor a registered model, so it is still surfaced as unresolved.
859
+ def _association_target_resolvable?(target)
860
+ name = target.to_s
861
+ return false if name.empty?
862
+ return true if Parse::Model::SYSTEM_CLASS_MAP.key?(name) ||
863
+ Parse::Model::SYSTEM_CLASS_MAP.value?(name)
864
+ !Parse::Model.find_class(name).nil?
865
+ end
866
+
717
867
  # Check if MCP server feature is enabled
718
868
  # Requires PARSE_MCP_ENABLED=true in environment AND Parse.mcp_server_enabled = true
719
869
  # @return [Boolean]
@@ -790,6 +940,23 @@ module Parse
790
940
  end
791
941
  Parse.client.send_analytics(event_name, dimensions, **opts)
792
942
  end
943
+
944
+ # Capability probe against the connected Parse Server, delegated to the
945
+ # default client. Builds on the memoized `serverInfo` fetch — see
946
+ # {Parse::API::Server#server_supports?} for the capability table and the
947
+ # fail-open-to-modern semantics.
948
+ # @param feature [Symbol] a capability key.
949
+ # @return [Boolean] whether the connected server supports the feature.
950
+ def server_supports?(feature)
951
+ Parse.client.server_supports?(feature)
952
+ end
953
+
954
+ # The coarse `features` block advertised by `GET /serverInfo`, delegated
955
+ # to the default client. @see Parse::API::Server#server_features
956
+ # @return [Hash] the advertised features block, or `{}` if unavailable.
957
+ def server_features
958
+ Parse.client.server_features
959
+ end
793
960
  end
794
961
 
795
962
  # Error raised when {Parse::CreateLock#synchronize} cannot acquire the
@@ -851,4 +1018,9 @@ end
851
1018
  # the setter on load.
852
1019
  Parse._attach_slow_query_subscriber! if Parse.slow_query_threshold_ms
853
1020
 
1021
+ # Install the lazy pluralized class-name alias hook (Posts -> Post). Loaded
1022
+ # last so Parse::Object and the Parse.__pluralized_alias_for helper are
1023
+ # already defined. Gated at runtime on Parse.pluralized_aliases?.
1024
+ require_relative "model/core/pluralized_aliases"
1025
+
854
1026
  require_relative "stack/railtie" if defined?(::Rails)
@@ -155,7 +155,7 @@ module Parse
155
155
  },
156
156
  }
157
157
 
158
- response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
158
+ response = client.update_user(id, { authData: auth_data_payload }, session_token: session_token)
159
159
 
160
160
  if response.error?
161
161
  if response.result.to_s.include?("Invalid MFA")
@@ -208,7 +208,7 @@ module Parse
208
208
  },
209
209
  }
210
210
 
211
- response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
211
+ response = client.update_user(id, { authData: auth_data_payload }, session_token: session_token)
212
212
 
213
213
  if response.error?
214
214
  raise Parse::Client::ResponseError, response
@@ -245,7 +245,7 @@ module Parse
245
245
  },
246
246
  }
247
247
 
248
- response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
248
+ response = client.update_user(id, { authData: auth_data_payload }, session_token: session_token)
249
249
 
250
250
  if response.error?
251
251
  if response.result.to_s.include?("Invalid MFA token")
@@ -276,27 +276,60 @@ module Parse
276
276
  raise MFA::NotEnabledError, "MFA is not enabled for this user" unless mfa_enabled?
277
277
  raise ArgumentError, "Current token is required" if current_token.blank?
278
278
 
279
- # To disable, we need to update authData.mfa with the old token for validation
280
- # and then set it to null
281
- auth_data_payload = {
282
- mfa: {
283
- old: current_token,
284
- secret: nil, # Setting to nil disables TOTP
285
- },
286
- }
287
-
288
- response = client.update_user(id, { authData: auth_data_payload }, opts: { session_token: session_token })
289
-
290
- if response.error?
291
- if response.result.to_s.include?("Invalid MFA token")
292
- raise MFA::VerificationError, response.result.to_s
293
- end
294
- raise Parse::Client::ResponseError, response
279
+ # Parse Server's TOTP adapter exposes no first-class "disable via authData
280
+ # update" path its validateUpdate always re-runs setup, so a partial
281
+ # mfa payload is rejected outright. Disabling is therefore a two-step:
282
+ #
283
+ # 1. Prove possession of the current code by submitting it as
284
+ # `{ mfa: { old: <token> } }`. In the *update* context (unlike a
285
+ # fresh login) the adapter validates that code against the stored
286
+ # secret. A WRONG code fails at validateLogin ("Invalid MFA token");
287
+ # a CORRECT code passes validateLogin and is then blocked by the
288
+ # re-setup requirement ("Invalid MFA data") which is precisely the
289
+ # signal that the code was accepted. (This re-entry of the current
290
+ # code is the deliberate confirmation gate for turning MFA off.)
291
+ # 2. Disable MFA by unlinking the provider with `{ mfa: nil }`.
292
+ #
293
+ # This keeps self-disable gated on a valid current code even though the
294
+ # server offers no dedicated TOTP self-disable endpoint.
295
+ verify = client.update_user(id, { authData: { mfa: { old: current_token } } },
296
+ session_token: session_token)
297
+ # Classify the two-step response POSITIVELY instead of treating
298
+ # "anything that isn't success-or-one-magic-string" as a bad
299
+ # token. The current code is ACCEPTED iff the server either
300
+ # succeeds or rejects only the follow-on re-setup ("Invalid MFA
301
+ # data") — that block fires AFTER validateLogin has already
302
+ # accepted the code. A WRONG code fails earlier at validateLogin
303
+ # ("Invalid MFA token"). Any OTHER error (transport, session, 5xx)
304
+ # is a real fault surfaced as-is, not mislabeled a verification
305
+ # failure.
306
+ err = verify.error.to_s
307
+ code_rejected = err.match?(/Invalid MFA token/i)
308
+ code_accepted = verify.success? || err.match?(/Invalid MFA data/i)
309
+ if code_rejected
310
+ raise MFA::VerificationError, "Invalid MFA token"
311
+ elsif !code_accepted
312
+ raise Parse::Client::ResponseError, verify
295
313
  end
296
314
 
297
- # Refresh auth_data
298
- fetch
315
+ response = client.update_user(id, { authData: { mfa: nil } }, session_token: session_token)
316
+ raise Parse::Client::ResponseError, response if response.error?
317
+
318
+ # CONFIRM the disable took effect from the SERVER's own view — a
319
+ # positive post-condition rather than trusting the unlink response
320
+ # alone. We must read the server directly here, NOT lean on the
321
+ # in-memory #mfa_enabled? projection: Parse Server omits +authData+
322
+ # entirely for a user with no providers, so once MFA is unlinked an
323
+ # ordinary fetch carries no +authData+ key at all and therefore can
324
+ # never clear the +{ mfa: { status: "enabled" } }+ value pinned at
325
+ # enrollment. An enabled account's own (session-token) read returns
326
+ # +authData.mfa+; a disabled one omits it — so an absent/mfa-less
327
+ # authData on this trusted self-read is the authoritative signal.
328
+ if mfa_enabled_on_server?
329
+ raise MFA::VerificationError, "MFA disable did not take effect (still enabled after unlink)"
330
+ end
299
331
 
332
+ clear_local_mfa_projection!
300
333
  true
301
334
  end
302
335
 
@@ -315,20 +348,26 @@ module Parse
315
348
  #
316
349
  # @param authorized_by [Parse::User, Parse::Pointer] the operator
317
350
  # performing the override. Required.
318
- # @param admin_role [Parse::Role, String, nil] optional role (or role
319
- # name) that +authorized_by+ must belong to.
351
+ # @param admin_role [Parse::Role, String, nil] role (or role name)
352
+ # that +authorized_by+ must belong to. Library-enforced. Either
353
+ # this or +allow_unverified: true+ is REQUIRED (fail-closed).
354
+ # @param allow_unverified [Boolean] explicitly accept caller-side
355
+ # authorization without a library role check. Defaults to +false+;
356
+ # must be set deliberately to bypass MFA without an +admin_role+.
320
357
  # @return [Boolean] True if disabled successfully.
321
358
  # @raise [ArgumentError] when +authorized_by:+ is missing or not a User.
322
- # @raise [Parse::MFA::ForbiddenError] when +admin_role+ is supplied
359
+ # @raise [Parse::MFA::ForbiddenError] when neither +admin_role+ nor
360
+ # +allow_unverified:+ is supplied, or when +admin_role+ is supplied
323
361
  # and the operator is not a member.
324
362
  #
325
- # @example Caller-verified authorization
326
- # user.disable_mfa_master_key!(authorized_by: current_admin)
327
- #
328
- # @example Library-enforced role check
363
+ # @example Library-enforced role check (preferred)
329
364
  # user.disable_mfa_master_key!(authorized_by: current_admin,
330
365
  # admin_role: "Admin")
331
- def disable_mfa_master_key!(authorized_by:, admin_role: nil)
366
+ #
367
+ # @example Caller-verified authorization (explicit opt-out)
368
+ # user.disable_mfa_master_key!(authorized_by: current_admin,
369
+ # allow_unverified: true)
370
+ def disable_mfa_master_key!(authorized_by:, admin_role: nil, allow_unverified: false)
332
371
  operator = authorized_by
333
372
  unless operator.is_a?(Parse::User) ||
334
373
  (operator.is_a?(Parse::Pointer) && operator.parse_class == Parse::User.parse_class)
@@ -340,6 +379,18 @@ module Parse
340
379
  raise ArgumentError, "authorized_by: User must be persisted (have an objectId)"
341
380
  end
342
381
 
382
+ # FAIL CLOSED: this method bypasses MFA verification entirely via
383
+ # the master key, so it refuses to run without SOME authorization
384
+ # signal. Either supply an `admin_role:` for the library to verify,
385
+ # or pass `allow_unverified: true` to deliberately assert that the
386
+ # caller has already authorized the operator out-of-band.
387
+ if admin_role.nil? && !allow_unverified
388
+ raise MFA::ForbiddenError,
389
+ "disable_mfa_master_key! refuses to bypass MFA without an authorization " \
390
+ "check: pass admin_role: to enforce role membership, or " \
391
+ "allow_unverified: true to explicitly accept caller-side authorization."
392
+ end
393
+
343
394
  if admin_role
344
395
  role = admin_role.is_a?(Parse::Role) ? admin_role : Parse::Role.find_by_name(admin_role.to_s)
345
396
  if role.nil?
@@ -357,14 +408,19 @@ module Parse
357
408
  end
358
409
 
359
410
  auth_data_payload = { mfa: nil }
360
- response = client.update_user(id, { authData: auth_data_payload }, opts: { use_master_key: true })
411
+ response = client.update_user(id, { authData: auth_data_payload }, use_master_key: true)
361
412
 
362
413
  if response.error?
363
414
  raise Parse::Client::ResponseError, response
364
415
  end
365
416
 
366
- # Refresh auth_data
417
+ # Refresh auth_data, then drop the in-memory MFA projection. As in
418
+ # #disable_mfa!, a disabled user's read omits +authData+, so the
419
+ # +{ mfa: { status: "enabled" } }+ value pinned at enrollment won't
420
+ # self-clear on fetch — clear it explicitly so #mfa_enabled? reports
421
+ # the truth after a master-key disable.
367
422
  fetch
423
+ clear_local_mfa_projection!
368
424
 
369
425
  true
370
426
  end
@@ -435,6 +491,42 @@ module Parse
435
491
  account_name = email.presence || username.presence || id
436
492
  MFA.qr_code(secret, account_name, issuer: issuer, format: format)
437
493
  end
494
+
495
+ private
496
+
497
+ # @!visibility private
498
+ # Authoritative server-side MFA check via a trusted self-read.
499
+ # Reads +authData.mfa+ straight from a fresh session-token fetch
500
+ # rather than the (possibly stale) in-memory projection. An enabled
501
+ # account returns +authData.mfa+ with a +status+/+secret+; a disabled
502
+ # one omits +authData+ — so absence (or an mfa-less authData) means
503
+ # disabled.
504
+ # @return [Boolean]
505
+ def mfa_enabled_on_server?
506
+ result = client.fetch_object(self.class.parse_class, id,
507
+ session_token: session_token).result
508
+ mfa = result.is_a?(Hash) ? result["authData"] : nil
509
+ mfa = mfa["mfa"] if mfa.is_a?(Hash)
510
+ mfa.is_a?(Hash) && (mfa["status"] == "enabled" || mfa["secret"].present?)
511
+ end
512
+
513
+ # @!visibility private
514
+ # Drop the in-memory MFA projection after a disable. A disabled user's
515
+ # server read omits +authData+ entirely, so an ordinary fetch can
516
+ # never clear the +{ mfa: { status: "enabled" } }+ value pinned at
517
+ # enrollment; do it explicitly here. Only the +mfa+ subkey is removed
518
+ # (any anonymous/OAuth authData is preserved), and the assignment runs
519
+ # through the non-dirtying hydration path inside a +with_authdata_trust+
520
+ # scope so it is neither stripped nor marked dirty — a later #save will
521
+ # not resend +authData+.
522
+ def clear_local_mfa_projection!
523
+ cleared = auth_data.is_a?(Hash) ? auth_data.dup : {}
524
+ cleared.delete("mfa")
525
+ cleared.delete(:mfa)
526
+ self.class.with_authdata_trust do
527
+ apply_attributes!({ "authData" => cleared }, dirty_track: false)
528
+ end
529
+ end
438
530
  end
439
531
 
440
532
  # Not enabled error