parse-stack-next 5.7.6 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
data/lib/parse/stack.rb CHANGED
@@ -62,6 +62,25 @@ module Parse
62
62
  # the first attempt and gone by the retry; the fiber-local state lives
63
63
  # for the lifetime of the block).
64
64
  #
65
+ # Mongo-direct reads follow the same rule: an explicit `master: true`
66
+ # passed to `Parse::MongoDB.aggregate`, a `Parse::Query` direct terminal,
67
+ # Atlas Search, or vector search runs in the public scope inside the
68
+ # block (or raises `ACLRequired` when `require_session_token` is on), and
69
+ # the `parse.mongodb.aggregate` notification payload carries
70
+ # `master_dropped: true`. `Parse::AtlasSearch.faceted_search` refuses to
71
+ # run inside the block. A LiveQuery admin connection opened inside the
72
+ # block sends no master key; one that already connected with it stays
73
+ # elevated, because Parse Server authorizes per connection.
74
+ #
75
+ # The SDK's own metadata reads keep the master key inside the block: the
76
+ # class schemas and role graph it reads to enforce a scope, index
77
+ # statistics, and the Atlas Search index listing. Those requests are
78
+ # marked individually; the block's state is never lifted while they run.
79
+ #
80
+ # This is a guard against accidental master-key use, not an isolation
81
+ # boundary. Code inside the block can call {Parse.with_master_key}, and
82
+ # `Parse::MongoDB.find` and `Parse::MongoDB.indexes` take no scope at all.
83
+ #
65
84
  # @yield runs the block with master-key disabled
66
85
  # @return [Object] the block's return value
67
86
  # @example
@@ -115,9 +134,12 @@ module Parse
115
134
  #
116
135
  # The `token` argument may be a String, a {Parse::User} (its
117
136
  # `session_token` is read), a {Parse::Session} (its `session_token` is
118
- # read), or `nil`. Passing `nil` clears the ambient inside the block —
119
- # useful for performing one anonymous call inside an otherwise
120
- # session-scoped region.
137
+ # read), or `nil`. Passing `nil`, or a user or session that carries no
138
+ # token, runs the block ANONYMOUSLY: requests inside it send neither a
139
+ # session token nor the master key, and a client's bound token is not
140
+ # used either. This is useful for performing one anonymous call inside an
141
+ # otherwise session-scoped region. A call inside the block can still opt
142
+ # out with an explicit `session_token:` or `use_master_key: true`.
121
143
  #
122
144
  # Fiber-local, not thread-local: concurrent fibers (and threads, since
123
145
  # each thread starts with its own root fiber) do not share state.
@@ -156,17 +178,37 @@ module Parse
156
178
  "token is refused so the block cannot silently execute with master-key " \
157
179
  "authority — pass a valid session token, or `nil` for no ambient session."
158
180
  end
159
- Fiber[SESSION_TOKEN_STATE_KEY] = resolved
181
+ # `nil` (no token, or a user/session without one) installs the
182
+ # anonymous sentinel rather than clearing the slot. A cleared slot meant
183
+ # "no ambient", which the request layer resolves to the master key on a
184
+ # master-keyed client: the opposite of the documented anonymous block.
185
+ Fiber[SESSION_TOKEN_STATE_KEY] = resolved.nil? ? ANONYMOUS_SESSION : resolved
160
186
  yield
161
187
  ensure
162
188
  Fiber[SESSION_TOKEN_STATE_KEY] = previous
163
189
  end
164
190
 
191
+ # Fiber-state sentinel installed by `Parse.with_session(nil)`. The request
192
+ # layer treats it as "anonymous": no session token and no master key.
193
+ # @!visibility private
194
+ ANONYMOUS_SESSION = :__parse_anonymous_session__
195
+
165
196
  # The ambient session token set by {.with_session} for the current
166
- # fiber, or `nil` when not inside such a block.
197
+ # fiber, or `nil` when not inside such a block or inside an anonymous
198
+ # `with_session(nil)` block (see {.anonymous_session?}).
167
199
  # @return [String, nil]
168
200
  def self.current_session_token
169
- Fiber[SESSION_TOKEN_STATE_KEY]
201
+ value = Fiber[SESSION_TOKEN_STATE_KEY]
202
+ value == ANONYMOUS_SESSION ? nil : value
203
+ end
204
+
205
+ # Whether the current fiber is inside an anonymous `Parse.with_session(nil)`
206
+ # block (directly, or nested without a token-bearing block inside it).
207
+ # Requests there carry no session token and no master key unless the call
208
+ # passes `session_token:` or `use_master_key: true` explicitly.
209
+ # @return [Boolean]
210
+ def self.anonymous_session?
211
+ Fiber[SESSION_TOKEN_STATE_KEY] == ANONYMOUS_SESSION
170
212
  end
171
213
 
172
214
  # @!visibility private
@@ -776,10 +818,17 @@ module Parse
776
818
  # - the name singularizes to a *different* string (i.e. looks plural),
777
819
  # - the singular form does NOT already end in `s` (per design: classes
778
820
  # whose name ends in `s` are not auto-aliased),
779
- # - the singular constant is defined (searching ancestors so a
780
- # top-level model is visible from a nested reference) and is a
781
- # `Parse::Object` subclass,
782
- # - the plural is not already defined on the referencing module.
821
+ # - the singular constant resolves from the referencing module and
822
+ # is a `Parse::Object` subclass,
823
+ # - the plural is not already defined in the singular class's own
824
+ # namespace.
825
+ #
826
+ # The alias is installed in the namespace that defines the singular
827
+ # class (`Object` for a top-level `Post`, `Blog` for `Blog::Post`), never
828
+ # on the module where the lookup happened. A reference to `Posts` from
829
+ # inside an unrelated class or module therefore resolves, but does not
830
+ # add a `Posts` constant to that module. A frozen namespace is left
831
+ # alone (no `FrozenError`).
783
832
  #
784
833
  # @param mod [Module] the module/class on which `const_missing` fired.
785
834
  # @param name [Symbol] the missing constant name.
@@ -795,15 +844,40 @@ module Parse
795
844
  return nil unless mod.const_defined?(sym, true)
796
845
  klass = mod.const_get(sym)
797
846
  return nil unless klass.is_a?(Class) && klass < Parse::Object
798
- return nil if mod.const_defined?(name, false)
799
- mod.const_set(name, klass)
847
+ home = __pluralized_alias_home(klass, sym)
848
+ return nil if home.nil?
849
+ if home.const_defined?(name, false)
850
+ existing = home.const_get(name, false)
851
+ return existing.equal?(klass) ? klass : nil
852
+ end
853
+ return nil if home.frozen?
854
+ home.const_set(name, klass)
800
855
  klass
801
- rescue NameError, LoadError
856
+ rescue NameError, LoadError, FrozenError
802
857
  # const_get/const_defined? can raise on malformed names or autoload
803
858
  # failures; never let alias resolution mask the original lookup.
804
859
  nil
805
860
  end
806
861
 
862
+ # @!visibility private
863
+ # The namespace that directly defines `klass` under the name `singular`,
864
+ # which is where its pluralized alias belongs. nil when it cannot be
865
+ # determined (anonymous class, or a constant that only resolves through
866
+ # an ancestor).
867
+ # @param klass [Class]
868
+ # @param singular [Symbol]
869
+ # @return [Module, nil]
870
+ def __pluralized_alias_home(klass, singular)
871
+ name = klass.name
872
+ return nil if name.nil?
873
+ parent_name = name.rpartition("::").first
874
+ home = parent_name.empty? ? ::Object : ::Object.const_get(parent_name, false)
875
+ return nil unless home.is_a?(Module) && home.const_defined?(singular, false)
876
+ home.const_get(singular, false).equal?(klass) ? home : nil
877
+ rescue NameError
878
+ nil
879
+ end
880
+
807
881
  # Verify that every association target across the loaded {Parse::Object}
808
882
  # subclasses resolves to a known Parse class. Covers `belongs_to` and
809
883
  # `property … as:` pointer targets (via each class's `references`),
@@ -1048,4 +1122,7 @@ Parse._attach_slow_query_subscriber! if Parse.slow_query_threshold_ms
1048
1122
  # already defined. Gated at runtime on Parse.pluralized_aliases?.
1049
1123
  require_relative "model/core/pluralized_aliases"
1050
1124
 
1051
- require_relative "stack/railtie" if defined?(::Rails)
1125
+ # Only hook into Rails when railties is loaded. A bare `Rails` module (for
1126
+ # example the one rails-html-sanitizer defines) has no `Rails::Railtie`, and
1127
+ # subclassing it would abort loading the gem.
1128
+ require_relative "stack/railtie" if defined?(::Rails::Railtie)
@@ -50,8 +50,14 @@ module Parse
50
50
  def login_with_mfa(username, password, mfa_token)
51
51
  raise MFA::RequiredError, "MFA token is required" if mfa_token.blank?
52
52
 
53
+ # `client.login_with_mfa` always goes out without the master key
54
+ # (see Parse::API::Users#login_with_mfa): with it, Parse Server
55
+ # would skip this verification and overwrite the enrolled secret.
53
56
  response = client.login_with_mfa(username, password, mfa_token)
54
- return nil unless response.success?
57
+ unless response.success?
58
+ raise MFA::VerificationError, response.error.to_s if MFA.invalid_token_response?(response)
59
+ return nil
60
+ end
55
61
 
56
62
  # Self-fetch trust: an MFA login returns the authenticating
57
63
  # user's own row, so authData here is legitimately theirs.
@@ -447,8 +453,14 @@ module Parse
447
453
  # user = Parse::User.first
448
454
  # user.login_with_mfa!("password123", "123456")
449
455
  def login_with_mfa!(password, mfa_token = nil)
456
+ raise MFA::RequiredError, "MFA token is required for this account" if mfa_token.blank?
450
457
  response = client.login_with_mfa(username.to_s, password.to_s, mfa_token)
451
- apply_attributes!(response.result)
458
+ unless response.success?
459
+ raise MFA::VerificationError, response.error.to_s if MFA.invalid_token_response?(response)
460
+ return false
461
+ end
462
+ # Self-fetch trust: the login response is this user's own row.
463
+ self.class.with_authdata_trust { apply_attributes!(response.result) }
452
464
  session_token.present?
453
465
  rescue Parse::Client::ResponseError => e
454
466
  if e.message.include?("Missing additional authData")
@@ -62,6 +62,17 @@ module Parse
62
62
  end
63
63
  end
64
64
 
65
+ # Whether a failed login response is Parse Server rejecting the MFA code
66
+ # (as opposed to wrong credentials). Parse Server's MFA adapter throws a
67
+ # bare string, which reaches the client as code 141 with the adapter's
68
+ # message, e.g. "Invalid MFA token".
69
+ # @!visibility private
70
+ # @param response [Parse::Response]
71
+ # @return [Boolean]
72
+ def self.invalid_token_response?(response)
73
+ response.respond_to?(:error) && response.error.to_s.start_with?("Invalid MFA token", "Please enter the token")
74
+ end
75
+
65
76
  # Error raised when MFA is required but not provided
66
77
  class RequiredError < Parse::Error
67
78
  def initialize(message = "MFA token is required for this account")
@@ -399,9 +399,10 @@ module Parse
399
399
  # Build the native `$rankFusion` pipeline (without ACL/CLP
400
400
  # stages). Public-ish via {.native_pipeline} for snapshot tests;
401
401
  # the live path appends ACL enforcement in {#run_native}.
402
- def build_rank_fusion_stage(lex, vec, oversample, k_constant:, weights:)
403
- vsel = vector_search_stage(vec, oversample)
404
- lsel = lexical_search_stage(lex, oversample)
402
+ def build_rank_fusion_stage(lex, vec, oversample, k_constant:, weights:,
403
+ owner_vector_filter: nil, owner_match: nil)
404
+ vsel = vector_search_stage(vec, oversample, owner_filter: owner_vector_filter)
405
+ lsel = lexical_search_stage(lex, oversample, owner_match: owner_match)
405
406
  stage = {
406
407
  "input" => {
407
408
  "pipelines" => { "vector" => vsel, "lexical" => lsel },
@@ -437,19 +438,44 @@ module Parse
437
438
  # AFTER the ACL `$match`, so trimming to `k` there would
438
439
  # reintroduce the underfill the window exists to prevent. The
439
440
  # trim to `k` happens client-side once enforcement is done.
441
+ pointer_fields = resolve_pointer_fields!(collection_name, resolution)
440
442
  native_pipeline_for(lex, vec, candidate_window, resolution,
441
443
  k_constant: fusion[:k_constant] || DEFAULT_K_CONSTANT,
442
- weights: fusion[:weights], limit: candidate_window)
443
- end
444
-
445
- def native_pipeline_for(lex, vec, oversample, resolution, k_constant:, weights:, limit:)
446
- pipeline = [build_rank_fusion_stage(lex, vec, oversample, k_constant: k_constant, weights: weights)]
444
+ weights: fusion[:weights], limit: candidate_window,
445
+ pointer_fields: pointer_fields,
446
+ owner_vector_filter: owner_vector_filter(collection_name, vec, pointer_fields, resolution))
447
+ end
448
+
449
+ def native_pipeline_for(lex, vec, oversample, resolution, k_constant:, weights:, limit:,
450
+ pointer_fields: nil, owner_vector_filter: nil)
451
+ scoped = !(resolution.nil? || resolution.master?)
452
+ # Ownership inside each `$rankFusion` input as well as after it:
453
+ # every input is capped at `oversample`, so a final `$match`
454
+ # alone only sees the fused top rows and can underfill when
455
+ # other users' rows dominate both branches. The lexical input
456
+ # filters before its `$limit`; the vector input uses the
457
+ # `$vectorSearch.filter` prefilter when the owner path is
458
+ # filter-indexed and served (see Parse::VectorSearch).
459
+ owner_match = if scoped && pointer_fields
460
+ Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id)
461
+ end
462
+ pipeline = [build_rank_fusion_stage(lex, vec, oversample, k_constant: k_constant, weights: weights,
463
+ owner_vector_filter: (owner_vector_filter if scoped),
464
+ owner_match: owner_match)]
447
465
  # The fused RRF score is surfaced via `{ $meta: "score" }`
448
466
  # (a numeric), not "scoreDetails" (a breakdown document).
449
467
  pipeline << { "$addFields" => { "_hybrid_score" => { "$meta" => "score" } } }
450
468
  unless resolution.nil? || resolution.master?
451
469
  acl_match = Parse::ACLScope.match_stage_for(resolution)
452
470
  pipeline << acl_match if acl_match
471
+ # pointerFields / readUserFields ownership before `$limit`, so
472
+ # rows ranked above the caller's own rows cannot crowd them
473
+ # out of the page.
474
+ if pointer_fields
475
+ pipeline << {
476
+ "$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id),
477
+ }
478
+ end
453
479
  end
454
480
  pipeline << { "$sort" => { "_hybrid_score" => -1 } }
455
481
  pipeline << { "$limit" => limit }
@@ -463,13 +489,39 @@ module Parse
463
489
  protected_fields = Parse::CLPScope.protected_fields_for(
464
490
  collection_name, resolution.permission_strings,
465
491
  )
492
+ # Unconditional: `Parse::AtlasSearch` may already be partially
493
+ # defined by a submodule (session, protected_paths) without the
494
+ # main file loaded. `require_relative` is idempotent.
495
+ require_relative "../atlas_search"
496
+ Parse::AtlasSearch.send(:assert_search_fields_allowed!,
497
+ Array(lex[:fields]).map(&:to_s), protected_fields, resolution)
466
498
  Parse::VectorSearch.validate_query_vector!(vec[:query_vector])
467
499
  Parse::PipelineSecurity.validate_filter!(vec[:vector_filter]) if vec[:vector_filter]
468
500
  Parse::PipelineSecurity.validate_filter!(vec[:filter]) if vec[:filter]
469
501
  Parse::PipelineSecurity.validate_filter!(lex[:filter]) if lex[:filter]
502
+ # The client path gets these refusals from AtlasSearch.search and
503
+ # VectorSearch.search. The native path builds its own pipeline,
504
+ # so apply the same protected-field refusals here: the vector
505
+ # field, every filter's predicate keys, and `$expr` references.
506
+ Parse::VectorSearch.send(:assert_protected_fields_untouched!,
507
+ collection_name, vec[:field].to_s, vec[:filter],
508
+ vec[:vector_filter], protected_fields, resolution)
509
+ if lex[:filter] && !lex[:filter].empty? && !resolution.master?
510
+ Parse::PipelineSecurity.refuse_protected_field_references!(
511
+ [{ "$match" => lex[:filter] }], collection_name, resolution,
512
+ )
513
+ Parse::AtlasSearch::ProtectedPaths.assert_filter_allowed!(
514
+ lex[:filter], protected_fields, resolution,
515
+ collection_name: collection_name,
516
+ method_name: "Parse::VectorSearch::Hybrid.search",
517
+ )
518
+ end
470
519
 
520
+ owner_filter = owner_vector_filter(collection_name, vec, pointer_fields, resolution)
471
521
  pipeline = native_pipeline_for(lex, vec, oversample, resolution,
472
- k_constant: k_constant, weights: weights, limit: oversample)
522
+ k_constant: k_constant, weights: weights, limit: oversample,
523
+ pointer_fields: pointer_fields,
524
+ owner_vector_filter: owner_filter)
473
525
  rows = run_pipeline!(collection_name, pipeline,
474
526
  authorizing_client: Parse::ACLScope.client_of(resolution))
475
527
 
@@ -493,6 +545,8 @@ module Parse
493
545
  rows.select! { |doc| native_row_visible?(doc, perms_set) }
494
546
  Parse::ACLScope.redact_results!(rows, resolution)
495
547
  Parse::CLPScope.redact_protected_fields!(rows, protected_fields) if protected_fields.any?
548
+ # Defense in depth: the pointerFields `$match` already ran
549
+ # before `$limit`, so this should drop nothing.
496
550
  if pointer_fields
497
551
  rows = Parse::CLPScope.filter_by_pointer_fields(rows, pointer_fields, resolution.user_id)
498
552
  end
@@ -512,7 +566,13 @@ module Parse
512
566
  rows
513
567
  rescue Parse::CLPScope::Denied
514
568
  raise
515
- rescue StandardError
569
+ rescue StandardError => e
570
+ # A refused owner prefilter means Atlas serves an index version
571
+ # without that filter path; stop pushing it down so the client
572
+ # fallback below (and later searches) run without it.
573
+ if owner_filter && Parse::VectorSearch.owner_prefilter_rejected?(e, owner_filter)
574
+ Parse::VectorSearch.owner_prefilter_unavailable!(collection_name, vector_index_name(vec))
575
+ end
516
576
  # Native execution failed (e.g. a cluster that probed as
517
577
  # supported but rejects this exact shape, or a transient error).
518
578
  # Fall back to the client-side path rather than failing the
@@ -520,7 +580,23 @@ module Parse
520
580
  nil
521
581
  end
522
582
 
523
- def vector_search_stage(vec, oversample)
583
+ # The owner prefilter for the native vector input, or nil when it
584
+ # cannot be pushed down (master, no pointerFields, or the owner
585
+ # path not filter-indexed in the index Atlas is serving).
586
+ def owner_vector_filter(collection_name, vec, pointer_fields, resolution)
587
+ return nil if resolution.nil? || resolution.master? || pointer_fields.nil?
588
+ Parse::VectorSearch.send(:owner_vector_prefilter, collection_name, vector_index_name(vec),
589
+ pointer_fields, resolution)
590
+ end
591
+
592
+ # The vector index the native input searches: the branch's
593
+ # `index:` or {Parse::VectorSearch.default_index}, the same
594
+ # fallback {Parse::VectorSearch.search} uses on the client path.
595
+ def vector_index_name(vec)
596
+ vec[:index] || Parse::VectorSearch.default_index
597
+ end
598
+
599
+ def vector_search_stage(vec, oversample, owner_filter: nil)
524
600
  # Parity with Parse::VectorSearch: Atlas requires
525
601
  # `numCandidates >= limit` and caps it at 10_000. The default
526
602
  # (`oversample * MULTIPLIER`) can blow past 10_000 for a large
@@ -531,19 +607,23 @@ module Parse
531
607
  num_candidates = (vec[:num_candidates] || oversample * Parse::VectorSearch::DEFAULT_NUM_CANDIDATES_MULTIPLIER).to_i
532
608
  num_candidates = [[num_candidates, oversample].max, 10_000].min
533
609
  stage = {
534
- "index" => vec[:index].to_s,
610
+ "index" => vector_index_name(vec).to_s,
535
611
  "path" => vec[:field].to_s,
536
612
  "queryVector" => vec[:query_vector],
537
613
  "numCandidates" => num_candidates,
538
614
  "limit" => oversample,
539
615
  }
540
- stage["filter"] = vec[:vector_filter] if vec[:vector_filter] && !vec[:vector_filter].empty?
616
+ caller_filter = vec[:vector_filter] if vec[:vector_filter] && !vec[:vector_filter].empty?
617
+ prefilter = [caller_filter, owner_filter].compact
618
+ unless prefilter.empty?
619
+ stage["filter"] = prefilter.size == 1 ? prefilter.first : { "$and" => prefilter }
620
+ end
541
621
  inner = [{ "$vectorSearch" => stage }]
542
622
  inner << { "$match" => vec[:filter] } if vec[:filter]
543
623
  inner
544
624
  end
545
625
 
546
- def lexical_search_stage(lex, oversample)
626
+ def lexical_search_stage(lex, oversample, owner_match: nil)
547
627
  require_relative "../atlas_search" if defined?(Parse::AtlasSearch::SearchBuilder).nil?
548
628
  builder = Parse::AtlasSearch::SearchBuilder.new(index_name: lex[:index])
549
629
  fields = lex[:fields]
@@ -552,7 +632,9 @@ module Parse
552
632
  else
553
633
  Array(fields).each { |f| builder.text(query: lex[:query], path: f.to_s, fuzzy: lex[:fuzzy]) }
554
634
  end
555
- inner = [builder.build, { "$limit" => oversample }]
635
+ inner = [builder.build]
636
+ inner << { "$match" => owner_match } if owner_match
637
+ inner << { "$limit" => oversample }
556
638
  inner << { "$match" => lex[:filter] } if lex[:filter]
557
639
  inner
558
640
  end
@@ -672,27 +754,22 @@ module Parse
672
754
  end
673
755
 
674
756
  def assert_clp_find!(collection_name, resolution)
675
- return if resolution.nil? || resolution.master?
676
- unless Parse::CLPScope.permits?(collection_name, :find, resolution.permission_strings)
677
- raise Parse::CLPScope::Denied.new(
678
- collection_name, :find,
679
- "CLP refuses find on '#{collection_name}' for the current hybrid-search scope.",
680
- )
681
- end
757
+ # Same CLP branch evaluation as Parse::MongoDB.aggregate (public,
758
+ # user, and role grants first; then pointerFields / readUserFields).
759
+ # Raises Parse::CLPScope::Denied when the scope cannot find at all.
760
+ Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
761
+ label: "hybrid-search")
762
+ nil
682
763
  end
683
764
 
684
765
  def resolve_pointer_fields!(collection_name, resolution)
685
- return nil if resolution.nil? || resolution.master?
686
- pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
687
- return nil if pointer_fields.nil?
688
- if resolution.user_id.nil?
689
- raise Parse::CLPScope::Denied.new(
690
- collection_name, :find,
691
- "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
692
- "but the current hybrid-search scope has no user_id.",
693
- )
694
- end
695
- pointer_fields
766
+ # nil when a public, user, or role grant already permits every row
767
+ # (Parse Server ignores pointer permissions then); otherwise the
768
+ # pointerFields plus readUserFields the rows must match. The older
769
+ # permits? / pointer_fields_for pair missed readUserFields entirely
770
+ # and over-restricted a public grant that also listed pointerFields.
771
+ Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
772
+ label: "hybrid-search")
696
773
  end
697
774
 
698
775
  def validate_weights!(weights)