parse-stack-next 4.5.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,995 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "pipeline_security"
5
+ require_relative "acl_scope"
6
+ require_relative "clp_scope"
7
+ require_relative "atlas_search/index_manager"
8
+ require_relative "atlas_search/search_builder"
9
+ require_relative "atlas_search/result"
10
+ require_relative "atlas_search/session"
11
+
12
+ module Parse
13
+ # Atlas Search module for MongoDB Atlas full-text search capabilities.
14
+ # Provides direct access to Atlas Search features bypassing Parse Server.
15
+ #
16
+ # @example Enable Atlas Search
17
+ # Parse::MongoDB.configure(uri: "mongodb+srv://...", enabled: true)
18
+ # Parse::AtlasSearch.configure(enabled: true, default_index: "default")
19
+ #
20
+ # @example Full-text search
21
+ # result = Parse::AtlasSearch.search("Song", "love", index: "song_search")
22
+ # result.results.each { |song| puts song.title }
23
+ #
24
+ # @example Autocomplete
25
+ # result = Parse::AtlasSearch.autocomplete("Song", "lov", field: :title)
26
+ # result.suggestions # => ["Love Story", "Lovely Day", "Love Me Do"]
27
+ #
28
+ # @note Requires the 'mongo' gem and a MongoDB Atlas cluster with Search enabled.
29
+ # Also works with local Atlas deployments created via `atlas deployments setup --type local`.
30
+ module AtlasSearch
31
+ # Error raised when Atlas Search is not available
32
+ class NotAvailable < StandardError; end
33
+
34
+ # Error raised when search index is not found
35
+ class IndexNotFound < StandardError; end
36
+
37
+ # Error raised for invalid search parameters
38
+ class InvalidSearchParameters < StandardError; end
39
+
40
+ # Error raised when the caller did not supply +session_token:+ or
41
+ # +master: true+ and {.require_session_token} is +true+. Atlas
42
+ # Search bypasses Parse Server's ACL evaluation, so the caller
43
+ # must either pass a session token (so the SDK can inject a
44
+ # +_rperm+ +$match+) or explicitly opt into master-key semantics.
45
+ class ACLRequired < StandardError; end
46
+
47
+ # Error raised when {.faceted_search} is called with a +session_token+.
48
+ # +$searchMeta+ returns a single metadata document — bucket
49
+ # counts that include restricted documents and cannot be
50
+ # post-filtered with +$match+ because the matched documents are
51
+ # not in the output stream. ACL-safe faceting requires the search
52
+ # index to tokenize +_rperm+ and a +compound.filter+ injection
53
+ # path; both are deferred to a follow-up release. Callers that
54
+ # need ACL-aware faceting today must either run with +master: true+
55
+ # or implement post-aggregation filtering themselves.
56
+ class FacetedSearchNotACLSafe < StandardError; end
57
+
58
+ class << self
59
+ # @!attribute [rw] enabled
60
+ # Feature flag to enable/disable Atlas Search.
61
+ # @return [Boolean]
62
+ attr_accessor :enabled
63
+
64
+ # @!attribute [rw] default_index
65
+ # Default search index name to use when none specified.
66
+ # @return [String]
67
+ attr_accessor :default_index
68
+
69
+ # @!attribute [rw] allow_raw
70
+ # Whether `raw: true` is honored on {.search}, {.autocomplete},
71
+ # and {.faceted_search}. When `false` (the default), `raw:` is
72
+ # ignored and callers receive converted Parse-format
73
+ # documents. Even when `true`, internal-fields denylist (cf.
74
+ # {Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST}) is
75
+ # ALWAYS stripped — there is no path that returns
76
+ # `_hashed_password`, `_session_token`, etc., regardless of
77
+ # `raw:`.
78
+ # @return [Boolean]
79
+ attr_accessor :allow_raw
80
+
81
+ # @!attribute [rw] require_session_token
82
+ # When +true+, {.search}, {.autocomplete}, and
83
+ # {.faceted_search} raise {ACLRequired} unless the caller
84
+ # passes either +session_token:+ or +master: true+. Default:
85
+ # +false+, matching the pre-ACL behavior — a one-time
86
+ # +[Parse::AtlasSearch:SECURITY]+ banner is emitted instead
87
+ # for missing-token calls, the same pattern used by
88
+ # {Parse::Agent} for master-key construction.
89
+ #
90
+ # New deployments are strongly encouraged to flip this to
91
+ # +true+ at startup. The next major release will flip the
92
+ # default.
93
+ # @return [Boolean]
94
+ attr_accessor :require_session_token
95
+
96
+ # @!attribute [rw] session_cache_ttl
97
+ # TTL (seconds) for {Session}'s session-token → user-id cache.
98
+ # Default: 3600 (1 hour). Longer values reduce +/users/me+
99
+ # round-trips but extend the window during which a revoked
100
+ # session can still authenticate Atlas Search calls; apps
101
+ # with sub-TTL revocation requirements should call
102
+ # {Session.invalidate} from their logout path.
103
+ # @return [Integer]
104
+ attr_accessor :session_cache_ttl
105
+
106
+ # @!attribute [rw] role_cache_ttl
107
+ # TTL (seconds) for {Session}'s user-id → role-name cache.
108
+ # Default: 120 (2 minutes). Short on purpose: stale role
109
+ # data yields incorrect ACL decisions, so the cache is sized
110
+ # to amortize within a single request/turn but expire well
111
+ # inside the response time the operator notices a role grant.
112
+ # @return [Integer]
113
+ attr_accessor :role_cache_ttl
114
+
115
+ # @!attribute [rw] session_cache
116
+ # Pluggable cache for {Session}'s session-token lookups.
117
+ # Replace with a Redis/Memcached adapter for cross-process
118
+ # sharing; the object must respond to +get(key)+,
119
+ # +set(key, value, ttl:)+, and +invalidate(key)+. Defaults
120
+ # to a process-local {Session::MemoryCache}.
121
+ # @return [#get, #set, #invalidate]
122
+ attr_accessor :session_cache
123
+
124
+ # @!attribute [rw] role_cache
125
+ # Pluggable cache for {Session}'s role-name lookups. See
126
+ # {.session_cache} for the interface contract.
127
+ # @return [#get, #set, #invalidate]
128
+ attr_accessor :role_cache
129
+
130
+ # Configure Atlas Search (uses Parse::MongoDB connection)
131
+ # @param enabled [Boolean] whether to enable Atlas Search (default: true)
132
+ # @param default_index [String] default search index name (default: "default")
133
+ # @param allow_raw [Boolean] whether `raw: true` is honored on
134
+ # search/autocomplete/faceted_search. Defaults to `false`
135
+ # (raw flag ignored) in production-like environments and
136
+ # `true` when RACK_ENV/RAILS_ENV is `development` or `test`.
137
+ # Internal-field stripping runs regardless.
138
+ # @param require_session_token [Boolean] when +true+, library
139
+ # calls without +session_token:+ or +master: true+ raise
140
+ # {ACLRequired}. See {#require_session_token}. Default: +false+.
141
+ # @param session_cache_ttl [Integer] session-token cache TTL
142
+ # (seconds). Default: 3600.
143
+ # @param role_cache_ttl [Integer] role-name cache TTL (seconds).
144
+ # Default: 120.
145
+ # @example
146
+ # Parse::AtlasSearch.configure(enabled: true, default_index: "default")
147
+ def configure(enabled: true,
148
+ default_index: "default",
149
+ allow_raw: nil,
150
+ require_session_token: nil,
151
+ session_cache_ttl: nil,
152
+ role_cache_ttl: nil)
153
+ Parse::MongoDB.require_gem!
154
+ @enabled = enabled
155
+ @default_index = default_index
156
+ @allow_raw = allow_raw.nil? ? default_allow_raw : allow_raw
157
+ @require_session_token = require_session_token unless require_session_token.nil?
158
+ @session_cache_ttl = session_cache_ttl unless session_cache_ttl.nil?
159
+ @role_cache_ttl = role_cache_ttl unless role_cache_ttl.nil?
160
+ IndexManager.clear_cache
161
+ end
162
+
163
+ # @!visibility private
164
+ #
165
+ # Default value for {#allow_raw}: permissive only when an
166
+ # explicit non-production environment is signalled. Bare-Ruby
167
+ # processes without `RACK_ENV`/`RAILS_ENV` get the strict
168
+ # default (raw refused) so a forgotten env-var tag can't
169
+ # downgrade security on a production deploy.
170
+ def default_allow_raw
171
+ env = ENV["RACK_ENV"] || ENV["RAILS_ENV"]
172
+ return false if env.nil?
173
+ %w[development test].include?(env)
174
+ end
175
+
176
+ # Check if Atlas Search is available and enabled
177
+ # @return [Boolean]
178
+ def available?
179
+ return false unless defined?(Parse::MongoDB)
180
+ Parse::MongoDB.available? && enabled?
181
+ end
182
+
183
+ # Check if Atlas Search is enabled
184
+ # @return [Boolean]
185
+ def enabled?
186
+ @enabled == true
187
+ end
188
+
189
+ # Reset Atlas Search configuration to first-load defaults.
190
+ # Clears the session/role caches as well; this is primarily a
191
+ # test helper.
192
+ def reset!
193
+ @enabled = false
194
+ @default_index = "default"
195
+ @allow_raw = default_allow_raw
196
+ @require_session_token = false
197
+ @session_cache_ttl = 3600
198
+ @role_cache_ttl = 120
199
+ @session_cache = Session::MemoryCache.new
200
+ @role_cache = Session::MemoryCache.new
201
+ @master_warned = false
202
+ IndexManager.clear_cache
203
+ end
204
+
205
+ # List search indexes for a collection (cached)
206
+ # @param collection_name [String] the Parse collection name
207
+ # @return [Array<Hash>] array of index definitions
208
+ def indexes(collection_name)
209
+ IndexManager.list_indexes(collection_name)
210
+ end
211
+
212
+ # Check if a search index exists and is ready
213
+ # @param collection_name [String] the Parse collection name
214
+ # @param index_name [String] the index name to check (default: default_index)
215
+ # @return [Boolean] true if index exists and is queryable
216
+ def index_ready?(collection_name, index_name = nil)
217
+ IndexManager.index_ready?(collection_name, index_name || @default_index)
218
+ end
219
+
220
+ # Force refresh the index cache for a collection
221
+ # @param collection_name [String] the Parse collection name (nil to clear all)
222
+ def refresh_indexes(collection_name = nil)
223
+ IndexManager.clear_cache(collection_name)
224
+ end
225
+
226
+ #----------------------------------------------------------------
227
+ # SEARCH OPERATIONS
228
+ #----------------------------------------------------------------
229
+
230
+ # Perform a full-text search using Atlas Search.
231
+ #
232
+ # @param collection_name [String] the Parse collection name (e.g., "Song")
233
+ # @param query [String] the search query text
234
+ # @param options [Hash] search options
235
+ # @option options [String] :index search index name (default: configured default_index)
236
+ # @option options [Array<String>, String, Symbol] :fields fields to search (default: all indexed fields)
237
+ # @option options [Boolean] :fuzzy enable fuzzy matching (default: false)
238
+ # @option options [Integer] :fuzzy_max_edits max edit distance for fuzzy (1 or 2, default: 2)
239
+ # @option options [Symbol, String] :highlight_field field to return highlights for
240
+ # @option options [Integer] :limit max results to return (default: 100)
241
+ # @option options [Integer] :skip number of results to skip (default: 0)
242
+ # @option options [Hash] :filter additional constraints to apply
243
+ # @option options [Hash] :sort sort specification (default: by relevance score)
244
+ # @option options [Boolean] :raw return raw MongoDB documents (default: false)
245
+ # @option options [String] :class_name Parse class name for object conversion
246
+ #
247
+ # @return [Parse::AtlasSearch::SearchResult] search result object
248
+ #
249
+ # @example Basic search
250
+ # result = Parse::AtlasSearch.search("Song", "love ballad")
251
+ # result.results.each { |song| puts song.title }
252
+ #
253
+ # @example Search with fuzzy matching and field restriction
254
+ # result = Parse::AtlasSearch.search("Song", "lvoe",
255
+ # fields: [:title, :lyrics],
256
+ # fuzzy: true,
257
+ # limit: 20
258
+ # )
259
+ def search(collection_name, query, **options)
260
+ require_available!
261
+ validate_search_params!(query)
262
+
263
+ # Wave-3b READPREF-4: read-preference is consumed at the
264
+ # collection-with-read-preference step inside run_atlas_pipeline!.
265
+ # Pop it here so it doesn't surface in `options` for any
266
+ # downstream consumer (SearchBuilder, recursive search()
267
+ # call from faceted_search) that iterates the hash.
268
+ read_preference = options.delete(:read_preference)
269
+ resolution = resolve_scope!(options, method_name: :search)
270
+
271
+ # Enforce CLP `find` (and pointerFields requirement) BEFORE
272
+ # we build / execute the pipeline. Without this, a scoped
273
+ # caller can issue $search against a collection whose CLP
274
+ # would refuse them on the equivalent REST find.
275
+ assert_clp_find!(collection_name, resolution)
276
+ pointer_fields = resolve_pointer_fields!(collection_name, resolution)
277
+
278
+ # Compute the protectedFields strip set early so we can
279
+ # refuse a highlight_field that's in it (ATLAS-4). Avoids
280
+ # the awkward "we return objects but secretly drop their
281
+ # highlights" state — fail loudly instead.
282
+ protected_fields = Parse::CLPScope.protected_fields_for(
283
+ collection_name, resolution.permission_strings,
284
+ )
285
+ assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)
286
+
287
+ index_name = options[:index] || @default_index
288
+ fields = normalize_fields(options[:fields])
289
+ limit = options[:limit] || 100
290
+ skip_val = options[:skip] || 0
291
+
292
+ # Build the $search stage
293
+ builder = SearchBuilder.new(index_name: index_name)
294
+
295
+ if fields.present?
296
+ fields.each do |field|
297
+ builder.text(query: query, path: field, fuzzy: options[:fuzzy])
298
+ end
299
+ else
300
+ builder.text(query: query, path: { "wildcard" => "*" }, fuzzy: options[:fuzzy])
301
+ end
302
+
303
+ if options[:highlight_field]
304
+ builder.with_highlight(path: options[:highlight_field])
305
+ end
306
+
307
+ # CRITICAL: $search MUST be stage 0 of an Atlas Search
308
+ # pipeline. MongoDB Atlas rejects pipelines whose first stage
309
+ # is anything other than $search/$searchMeta. Do NOT route
310
+ # through Parse::MongoDB.aggregate here — that helper prepends
311
+ # the ACL $match to position 0, which Atlas would reject. We
312
+ # build the pipeline manually with $search at index 0 and
313
+ # place the ACL $match AFTER $search (which is correct: $search
314
+ # has already produced its candidate set, the $match narrows it
315
+ # to ACL-readable rows, then the caller filter narrows further).
316
+ pipeline = [builder.build]
317
+
318
+ # Add score projection
319
+ pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }
320
+
321
+ # Add highlights projection if requested
322
+ if options[:highlight_field]
323
+ pipeline << { "$addFields" => { "_highlights" => { "$meta" => "searchHighlights" } } }
324
+ end
325
+
326
+ # Inject ACL $match BEFORE the caller-supplied filter (but AFTER
327
+ # $search and the $addFields stages) so the user-controlled
328
+ # filter cannot exfiltrate restricted documents that passed the
329
+ # $search operator. The $exists: false branch in `read_predicate`
330
+ # covers documents Parse Server treats as public (no _rperm).
331
+ unless resolution.master?
332
+ acl_match = Parse::ACLScope.match_stage_for(resolution)
333
+ pipeline << acl_match if acl_match
334
+ end
335
+
336
+ # Add filter stage if provided
337
+ if options[:filter]
338
+ mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name)
339
+ pipeline << { "$match" => mongo_filter }
340
+ end
341
+
342
+ # Add sort (default by score)
343
+ sort_spec = options[:sort] || { "_score" => -1 }
344
+ pipeline << { "$sort" => sort_spec }
345
+
346
+ # Add pagination
347
+ pipeline << { "$skip" => skip_val } if skip_val > 0
348
+ pipeline << { "$limit" => limit }
349
+
350
+ # Execute directly against the MongoDB collection — bypasses
351
+ # Parse::MongoDB.aggregate so its ACL-prepend doesn't violate
352
+ # the $search-at-stage-0 invariant. We're reproducing the
353
+ # SDK-side enforcement chain (ACL match, protectedFields strip,
354
+ # pointerFields filter, embedded sub-doc redaction) inline below.
355
+ raw_results = run_atlas_pipeline!(
356
+ collection_name, pipeline, options[:max_time_ms],
357
+ read_preference: read_preference,
358
+ )
359
+
360
+ # Post-fetch enforcement: walk the result rows the same way
361
+ # Parse::MongoDB.aggregate would. Master mode is the ACL bypass
362
+ # — skip every redaction layer (matches the helper's behavior).
363
+ unless resolution.master?
364
+ Parse::ACLScope.redact_results!(raw_results, resolution)
365
+ Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
366
+ if pointer_fields
367
+ raw_results = Parse::CLPScope.filter_by_pointer_fields(
368
+ raw_results, pointer_fields, resolution.user_id,
369
+ )
370
+ end
371
+ # ATLAS-4: drop any `_highlights` entry whose `path` names a
372
+ # protected field. `searchHighlights` returns the matched
373
+ # token plus its surrounding text, which would otherwise leak
374
+ # the protected field's value through the snippet.
375
+ strip_protected_highlights!(raw_results, protected_fields) if protected_fields.any?
376
+ end
377
+
378
+ # Convert results
379
+ class_name = options[:class_name] || collection_name
380
+ process_search_results(raw_results, class_name, options[:raw])
381
+ end
382
+
383
+ # Perform an autocomplete search for search-as-you-type functionality.
384
+ #
385
+ # @param collection_name [String] the Parse collection name
386
+ # @param query [String] the partial search query (prefix)
387
+ # @param field [Symbol, String] the field configured for autocomplete
388
+ # @param options [Hash] autocomplete options
389
+ # @option options [String] :index search index name (default: configured default_index)
390
+ # @option options [Boolean] :fuzzy enable fuzzy matching (default: false)
391
+ # @option options [Integer] :fuzzy_max_edits max edit distance (1 or 2, default: 1)
392
+ # @option options [String] :token_order "any" or "sequential" (default: "any")
393
+ # @option options [Integer] :limit max suggestions to return (default: 10)
394
+ # @option options [Hash] :filter additional constraints
395
+ # @option options [Boolean] :raw return raw documents (default: false)
396
+ #
397
+ # @return [Parse::AtlasSearch::AutocompleteResult] autocomplete result
398
+ #
399
+ # @example Basic autocomplete
400
+ # result = Parse::AtlasSearch.autocomplete("Song", "lov", field: :title)
401
+ # result.suggestions # => ["Love Story", "Lovely Day", "Love Me Do"]
402
+ def autocomplete(collection_name, query, field:, **options)
403
+ require_available!
404
+
405
+ raise InvalidSearchParameters, "field is required for autocomplete" if field.nil?
406
+ raise InvalidSearchParameters, "query must be a non-empty string" if query.nil? || query.to_s.strip.empty?
407
+
408
+ # Wave-3b READPREF-4: see #search for rationale.
409
+ read_preference = options.delete(:read_preference)
410
+ resolution = resolve_scope!(options, method_name: :autocomplete)
411
+
412
+ # Enforce CLP `find` (and pointerFields requirement) on the same
413
+ # collection autocomplete is about to scan. Without this an
414
+ # autocomplete UI on a protected class would silently surface
415
+ # the protected field's leading characters to any caller.
416
+ assert_clp_find!(collection_name, resolution)
417
+ pointer_fields = resolve_pointer_fields!(collection_name, resolution)
418
+
419
+ # ATLAS-4: refuse autocomplete on a protected field. The
420
+ # autocomplete operator returns the leading characters of the
421
+ # indexed field value verbatim — running autocomplete on, say,
422
+ # `email` when CLP marks `email` protected would defeat the
423
+ # protectedFields contract.
424
+ protected_fields = Parse::CLPScope.protected_fields_for(
425
+ collection_name, resolution.permission_strings,
426
+ )
427
+ field_str = field.to_s
428
+ if !resolution.master? && protected_fields.include?(field_str)
429
+ raise Parse::CLPScope::Denied.new(
430
+ collection_name, :find,
431
+ "Parse::AtlasSearch.autocomplete refused: field '#{field_str}' is in " \
432
+ "protectedFields for the current scope; autocompleting on it would " \
433
+ "leak the protected field's value.",
434
+ )
435
+ end
436
+
437
+ index_name = options[:index] || @default_index
438
+ limit = options[:limit] || 10
439
+
440
+ # Build autocomplete search stage
441
+ builder = SearchBuilder.new(index_name: index_name)
442
+ builder.autocomplete(
443
+ query: query.to_s,
444
+ path: field_str,
445
+ fuzzy: options[:fuzzy],
446
+ token_order: options[:token_order],
447
+ )
448
+
449
+ # CRITICAL: $search MUST be stage 0 of the pipeline (see
450
+ # comments in #search). Build manually; do NOT route through
451
+ # Parse::MongoDB.aggregate (which would prepend an ACL $match
452
+ # at position 0 and break Atlas's invariant).
453
+ pipeline = [builder.build]
454
+
455
+ # Add score
456
+ pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }
457
+
458
+ # Inject ACL $match AFTER $search/$addFields and BEFORE the
459
+ # caller-supplied filter; see {.search} for the rationale.
460
+ unless resolution.master?
461
+ acl_match = Parse::ACLScope.match_stage_for(resolution)
462
+ pipeline << acl_match if acl_match
463
+ end
464
+
465
+ # Add filter if provided
466
+ if options[:filter]
467
+ mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name)
468
+ pipeline << { "$match" => mongo_filter }
469
+ end
470
+
471
+ # Sort by score and limit
472
+ pipeline << { "$sort" => { "_score" => -1 } }
473
+ pipeline << { "$limit" => limit }
474
+
475
+ raw_results = run_atlas_pipeline!(
476
+ collection_name, pipeline, options[:max_time_ms],
477
+ read_preference: read_preference,
478
+ )
479
+
480
+ unless resolution.master?
481
+ Parse::ACLScope.redact_results!(raw_results, resolution)
482
+ Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
483
+ if pointer_fields
484
+ raw_results = Parse::CLPScope.filter_by_pointer_fields(
485
+ raw_results, pointer_fields, resolution.user_id,
486
+ )
487
+ end
488
+ end
489
+
490
+ # Extract suggestions (the field values). Run after the
491
+ # protectedFields strip / pointerFields filter so a redacted
492
+ # row can't surface its field value through the suggestion list.
493
+ suggestions = raw_results.map { |doc| doc[field_str] }.compact.uniq
494
+
495
+ # Convert to full objects if needed
496
+ class_name = options[:class_name] || collection_name
497
+ results = if raw_mode?(options[:raw])
498
+ sanitize_raw_results(raw_results)
499
+ else
500
+ parse_results = Parse::MongoDB.convert_documents_to_parse(raw_results, class_name)
501
+ parse_results.map { |doc| build_parse_object(doc, class_name) }.compact
502
+ end
503
+
504
+ AutocompleteResult.new(suggestions: suggestions, results: results)
505
+ end
506
+
507
+ # Perform a faceted search with category counts.
508
+ #
509
+ # @param collection_name [String] the Parse collection name
510
+ # @param query [String, nil] the search query text (nil for match-all)
511
+ # @param facets [Hash] facet definitions
512
+ # @param options [Hash] search options (same as #search)
513
+ #
514
+ # @return [Parse::AtlasSearch::FacetedResult] faceted result
515
+ #
516
+ # @example Faceted search by genre and year
517
+ # facets = {
518
+ # genre: { type: :string, path: :genre },
519
+ # decade: { type: :number, path: :year, boundaries: [1970, 1980, 1990, 2000, 2010] }
520
+ # }
521
+ # result = Parse::AtlasSearch.faceted_search("Song", "rock", facets)
522
+ # result.facets[:genre] # => [{ value: "Rock", count: 150 }, ...]
523
+ def faceted_search(collection_name, query, facets, **options)
524
+ require_available!
525
+
526
+ # Faceted search uses $searchMeta, which outputs a single
527
+ # metadata document — bucket counts can't be retroactively
528
+ # filtered by a post-$searchMeta $match because the matched
529
+ # documents are not in the output stream. ACL-aware faceting
530
+ # requires either tokenizing _rperm in the search index and
531
+ # injecting a compound.filter inside $searchMeta, or running
532
+ # two passes with manual aggregation. Both are deferred.
533
+ #
534
+ # Library-layer defense: refuse ANY scoped identity kwarg
535
+ # (session_token:, acl_user:, acl_role:) unless the caller
536
+ # explicitly accepts master-key semantics by also passing
537
+ # `master: true`. The original code only checked
538
+ # `session_token:`, leaving `acl_user:` / `acl_role:` callers
539
+ # (ATLAS-10) silently downgraded to the unauthenticated/
540
+ # public-mode banner branch — which on $searchMeta produces
541
+ # bucket counts that include rows the caller cannot read,
542
+ # exfiltrating restricted document counts and category
543
+ # values. Checking the raw options BEFORE resolve_scope!
544
+ # pops them so the error path can name what the caller
545
+ # actually passed.
546
+ scoped_kwargs = %i[session_token acl_user acl_role]
547
+ offending = scoped_kwargs.select { |k| !options[k].nil? }
548
+ if offending.any? && options[:master] != true
549
+ raise FacetedSearchNotACLSafe,
550
+ "Parse::AtlasSearch.faceted_search cannot enforce per-row " \
551
+ "ACL on $searchMeta bucket counts (got #{offending.first}:). " \
552
+ "Pass `master: true` to run with master-key semantics and " \
553
+ "accept that bucket counts include all rows, or use " \
554
+ "#search for ACL-scoped results without facets."
555
+ end
556
+ # Wave-3b READPREF-4: see #search for rationale. Captured
557
+ # before resolve_scope! pops the auth kwargs so the recursive
558
+ # search() call below can re-thread it explicitly (resolve!
559
+ # also strips it during that recursion).
560
+ read_preference = options.delete(:read_preference)
561
+ resolution = resolve_scope!(options, method_name: :faceted_search)
562
+ acl = { master: resolution.master? }
563
+
564
+ index_name = options[:index] || @default_index
565
+ limit = options[:limit] || 100
566
+ skip_val = options[:skip] || 0
567
+
568
+ # Build facet definitions for $searchMeta
569
+ facet_definitions = build_facet_definitions(facets)
570
+
571
+ search_meta_stage = {
572
+ "$searchMeta" => {
573
+ "index" => index_name,
574
+ "facet" => {
575
+ "facets" => facet_definitions,
576
+ },
577
+ },
578
+ }
579
+
580
+ # Add operator for the search query if present
581
+ if query.present?
582
+ fields = normalize_fields(options[:fields])
583
+ if fields.present?
584
+ should_clauses = fields.map do |field|
585
+ { "text" => { "query" => query, "path" => field } }
586
+ end
587
+ search_meta_stage["$searchMeta"]["facet"]["operator"] = {
588
+ "compound" => { "should" => should_clauses, "minimumShouldMatch" => 1 },
589
+ }
590
+ else
591
+ search_meta_stage["$searchMeta"]["facet"]["operator"] = {
592
+ "text" => { "query" => query, "path" => { "wildcard" => "*" } },
593
+ }
594
+ end
595
+ end
596
+
597
+ # Execute facet query. $searchMeta MUST be the only / first
598
+ # stage of its pipeline — Atlas rejects anything prepended.
599
+ # Bypass Parse::MongoDB.aggregate (which would prepend a
600
+ # public-mode ACL $match at position 0 under the no-auth-kwargs
601
+ # fallthrough) and call the collection directly. At this point
602
+ # the call is master-only by construction (the offending-kwargs
603
+ # check above ensures any scoped caller bailed out), so no
604
+ # ACL/CLP enforcement runs here either.
605
+ facet_pipeline = [search_meta_stage]
606
+ facet_results_raw = run_atlas_pipeline!(
607
+ collection_name, facet_pipeline, options[:max_time_ms],
608
+ read_preference: read_preference,
609
+ )
610
+
611
+ # Extract facet results
612
+ facet_data = {}
613
+ total_count = 0
614
+
615
+ if facet_results_raw.first
616
+ raw = facet_results_raw.first
617
+ total_count = raw.dig("count", "total") || 0
618
+
619
+ if raw["facet"]
620
+ facets.keys.each do |facet_name|
621
+ bucket_key = facet_name.to_s
622
+ if raw["facet"][bucket_key]
623
+ facet_data[facet_name] = raw["facet"][bucket_key]["buckets"].map do |bucket|
624
+ { value: bucket["_id"], count: bucket["count"] }
625
+ end
626
+ end
627
+ end
628
+ end
629
+ end
630
+
631
+ # Get actual results with regular $search. Forward master:
632
+ # explicitly because resolve_acl_options! popped it from the
633
+ # options hash; without re-adding it the recursive call would
634
+ # take the unauthenticated path and emit the banner a second
635
+ # time (or raise ACLRequired under strict mode). Re-thread
636
+ # read_preference: the same way for the same reason — the
637
+ # outer faceted_search popped it before delegating.
638
+ results = if limit > 0 && query.present?
639
+ search_opts = options.merge(limit: limit, skip: skip_val)
640
+ search_opts[:master] = true if acl[:master]
641
+ search_opts[:read_preference] = read_preference if read_preference
642
+ search(collection_name, query, **search_opts).results
643
+ else
644
+ []
645
+ end
646
+
647
+ FacetedResult.new(results: results, facets: facet_data, total_count: total_count)
648
+ end
649
+
650
+ private
651
+
652
+ def require_available!
653
+ Parse::MongoDB.require_gem!
654
+ unless available?
655
+ raise NotAvailable,
656
+ "Atlas Search is not available. Ensure Parse::MongoDB is configured " \
657
+ "and Parse::AtlasSearch.configure(enabled: true) has been called."
658
+ end
659
+ end
660
+
661
+ # Pop the auth-related kwargs (+:session_token+, +:master+,
662
+ # +:acl_user+, +:acl_role+) off +options+ and return a fully
663
+ # resolved {Parse::ACLScope::Resolution}. Replaces the old
664
+ # +resolve_acl_options!+ shim that returned a bare Hash — the
665
+ # post-fetch enforcement chain ({Parse::ACLScope.redact_results!},
666
+ # {Parse::CLPScope.redact_protected_fields!}, etc.) all consume a
667
+ # Resolution, so producing one here keeps the call sites uniform.
668
+ #
669
+ # Modes match {Parse::ACLScope::Resolution}:
670
+ #
671
+ # * +:session+ — +session_token:+ resolved, or +acl_user:+ /
672
+ # +acl_role:+ supplied. ACL+CLP+protectedFields enforcement
673
+ # runs in full.
674
+ # * +:master+ — +master: true+. ACL/CLP enforcement is bypassed
675
+ # (the caller has explicit master-key intent).
676
+ # * +:public+ — no scope kwargs supplied, +require_session_token+
677
+ # is +false+. A one-time banner is emitted and the call
678
+ # falls through with public-only ACL semantics — public-mode
679
+ # enforcement still runs (refused rows are filtered, the
680
+ # CLP allowlist is consulted), the perms set is just
681
+ # +["*"]+ rather than user-scoped.
682
+ #
683
+ # Raises {ACLRequired} when no scope kwargs are supplied and
684
+ # {.require_session_token} is +true+. The agent-tool path
685
+ # refuses unconditionally regardless of this toggle — see
686
+ # {Parse::Agent::Tools}.
687
+ def resolve_scope!(options, method_name:)
688
+ session_token = options.delete(:session_token)
689
+ master = options.delete(:master)
690
+ acl_user = options.delete(:acl_user)
691
+ acl_role = options.delete(:acl_role)
692
+
693
+ # 4-way mutex. Mirrors Parse::ACLScope.resolve!'s
694
+ # `provided.length > 1` check so an `acl_user:` + `acl_role:`
695
+ # combination, or any other 2-of-N, is refused. Chained `if`
696
+ # branches would silently accept 3-way / 4-way combinations.
697
+ provided = [
698
+ session_token,
699
+ master == true ? master : nil,
700
+ acl_user,
701
+ acl_role,
702
+ ].compact
703
+ if provided.length > 1
704
+ raise ArgumentError,
705
+ "Parse::AtlasSearch.#{method_name}: cannot pass more than one of " \
706
+ "session_token:, master: true, acl_user:, or acl_role:. Pick one."
707
+ end
708
+
709
+ if session_token
710
+ resolved = Session.resolve(session_token)
711
+ return Parse::ACLScope::Resolution.new(
712
+ mode: :session,
713
+ permission_strings: resolved.permission_strings,
714
+ user_id: resolved.user_id,
715
+ session: resolved,
716
+ )
717
+ end
718
+
719
+ if acl_user
720
+ return Parse::ACLScope.resolve_for_user(acl_user)
721
+ end
722
+
723
+ if acl_role
724
+ return Parse::ACLScope.resolve_for_role(acl_role)
725
+ end
726
+
727
+ if master == true
728
+ return Parse::ACLScope::Resolution.new(
729
+ mode: :master, permission_strings: nil, user_id: nil, session: nil,
730
+ )
731
+ end
732
+
733
+ if @require_session_token == true
734
+ raise ACLRequired,
735
+ "Parse::AtlasSearch.#{method_name} requires session_token: or " \
736
+ "master: true (or acl_user:/acl_role:). ACL enforcement is " \
737
+ "disabled when none is supplied; flip " \
738
+ "Parse::AtlasSearch.require_session_token = false to allow " \
739
+ "public-only fallback."
740
+ end
741
+
742
+ warn_no_acl_context_once!(method_name)
743
+ anonymous = Session::Resolved.new(nil, Set.new)
744
+ Parse::ACLScope::Resolution.new(
745
+ mode: :public,
746
+ permission_strings: anonymous.permission_strings,
747
+ user_id: nil,
748
+ session: anonymous,
749
+ )
750
+ end
751
+
752
+ # CLP `find` boundary check. Master-mode skips; for every other
753
+ # scope, refuse the call when the resolved claim set can't
754
+ # `find` on the collection. Mirrors what Parse::MongoDB.aggregate
755
+ # does inline (we can't reuse that path because of the $search-
756
+ # at-stage-0 invariant).
757
+ def assert_clp_find!(collection_name, resolution)
758
+ return if resolution.nil? || resolution.master?
759
+ unless Parse::CLPScope.permits?(collection_name, :find, resolution.permission_strings)
760
+ raise Parse::CLPScope::Denied.new(
761
+ collection_name, :find,
762
+ "CLP refuses find on '#{collection_name}' for the current Atlas Search scope.",
763
+ )
764
+ end
765
+ end
766
+
767
+ # Resolve and return pointerFields for `find` on the collection.
768
+ # Raises CLPScope::Denied when pointerFields is set but the
769
+ # current scope has no user_id (acl_role-only / public agents).
770
+ # Returns nil when master-mode or no pointerFields entry exists.
771
+ def resolve_pointer_fields!(collection_name, resolution)
772
+ return nil if resolution.nil? || resolution.master?
773
+ pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
774
+ return nil if pointer_fields.nil?
775
+ if resolution.user_id.nil?
776
+ raise Parse::CLPScope::Denied.new(
777
+ collection_name, :find,
778
+ "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
779
+ "but the current Atlas Search scope has no user_id.",
780
+ )
781
+ end
782
+ pointer_fields
783
+ end
784
+
785
+ # ATLAS-4: refuse `highlight_field:` when the field is in the
786
+ # resolved protectedFields set. searchHighlights returns the
787
+ # matched token plus surrounding chars verbatim; running it on
788
+ # a protected field would defeat the protectedFields contract.
789
+ # Master-mode skips (no protectedFields apply).
790
+ def assert_highlight_field_allowed!(highlight_field, protected_fields, resolution)
791
+ return if highlight_field.nil?
792
+ return if resolution.nil? || resolution.master?
793
+ return if protected_fields.nil? || protected_fields.empty?
794
+ path = highlight_field.to_s
795
+ return unless protected_fields.include?(path)
796
+ raise Parse::CLPScope::Denied.new(
797
+ nil, :find,
798
+ "Parse::AtlasSearch.search refused: highlight_field '#{path}' is in " \
799
+ "protectedFields for the current scope; returning highlights would " \
800
+ "leak the protected field's value.",
801
+ )
802
+ end
803
+
804
+ # Drop `_highlights` entries whose `path` matches a
805
+ # protectedFields entry. Defense-in-depth complement to
806
+ # {.assert_highlight_field_allowed!} — that gate refuses the
807
+ # SDK-set highlight_field; this scrubs any highlight payload
808
+ # that arrived through other code paths (e.g., builder reuse
809
+ # or a future caller-supplied highlight Hash).
810
+ def strip_protected_highlights!(documents, protected_fields)
811
+ return if documents.nil? || documents.empty?
812
+ return if protected_fields.nil? || protected_fields.empty?
813
+ protected_set = protected_fields.to_set
814
+ documents.each do |doc|
815
+ next unless doc.is_a?(Hash)
816
+ highlights = doc["_highlights"]
817
+ next unless highlights.is_a?(Array)
818
+ doc["_highlights"] = highlights.reject do |h|
819
+ h.is_a?(Hash) && protected_set.include?((h["path"] || h[:path]).to_s)
820
+ end
821
+ end
822
+ end
823
+
824
+ # Execute the Atlas Search pipeline directly against the MongoDB
825
+ # collection. Bypasses {Parse::MongoDB.aggregate} (which would
826
+ # prepend the ACL $match at stage 0 — Atlas rejects any pipeline
827
+ # whose stage 0 is not $search/$searchMeta). Timeout translation
828
+ # is preserved to match {Parse::MongoDB.aggregate}'s behavior.
829
+ #
830
+ # Wave-3b READPREF-4: optional `read_preference:` is normalized
831
+ # through the same `Parse::MongoDB.normalize_read_preference`
832
+ # helper {Parse::MongoDB.aggregate} uses so the kwarg semantics
833
+ # are identical on both paths (invalid values warn and route to
834
+ # primary; nil = no override).
835
+ def run_atlas_pipeline!(collection_name, pipeline, max_time_ms = nil, read_preference: nil)
836
+ agg_opts = {}
837
+ agg_opts[:max_time_ms] = max_time_ms if max_time_ms
838
+ coll = Parse::MongoDB.collection(collection_name)
839
+ if (mode = Parse::MongoDB.send(:normalize_read_preference, read_preference))
840
+ coll = coll.with(read: { mode: mode })
841
+ end
842
+ coll.aggregate(pipeline, agg_opts).to_a
843
+ rescue => e
844
+ # `raise_if_timeout!` is module-private on Parse::MongoDB; use
845
+ # `send` so we can reuse the timeout-translation logic without
846
+ # widening its public surface.
847
+ Parse::MongoDB.send(:raise_if_timeout!, e, collection_name, max_time_ms)
848
+ raise
849
+ end
850
+
851
+ # Emit a one-time +[Parse::AtlasSearch:SECURITY]+ banner the
852
+ # first time an Atlas Search call runs without a session_token
853
+ # and without an explicit +master: true+. Mirrors the
854
+ # warned-once pattern {Parse::Agent} uses for master-key
855
+ # construction so noisy logs don't drown out the warning, but
856
+ # one log line per process is enough to surface the misuse to
857
+ # operators.
858
+ def warn_no_acl_context_once!(method_name)
859
+ return if @master_warned == true
860
+ @master_warned = true
861
+ warn "[Parse::AtlasSearch:SECURITY] #{method_name} called without " \
862
+ "session_token: or master: true. The pipeline will enforce " \
863
+ "public-only ACL semantics (only documents with no _rperm or " \
864
+ "_rperm including \"*\"). Pass session_token: for per-user " \
865
+ "filtering, or master: true to confirm the master-key bypass " \
866
+ "is intentional. Set Parse::AtlasSearch.require_session_token " \
867
+ "= true to make this misuse an error instead of a warning."
868
+ end
869
+
870
+ def validate_search_params!(query)
871
+ raise InvalidSearchParameters, "query must be a string" unless query.is_a?(String)
872
+ raise InvalidSearchParameters, "query cannot be empty" if query.strip.empty?
873
+ end
874
+
875
+ def normalize_fields(fields)
876
+ return nil if fields.nil?
877
+ Array(fields).map(&:to_s)
878
+ end
879
+
880
+ def convert_filter_for_mongodb(filter, collection_name)
881
+ # The filter hash is interpolated directly into a `$match` stage in
882
+ # the search pipeline. A caller forwarding a user-controlled filter
883
+ # (search UI, autocomplete endpoint) must not be able to inject
884
+ # `$where`, `$function`, `$accumulator`, `$out`, or `$merge` here.
885
+ # `Parse::PipelineSecurity.validate_filter!` recurses through the
886
+ # hash and refuses any of those operators at any depth.
887
+ Parse::PipelineSecurity.validate_filter!(filter) if filter
888
+ filter
889
+ end
890
+
891
+ def build_facet_definitions(facets)
892
+ definitions = {}
893
+
894
+ facets.each do |name, config|
895
+ path = config[:path].to_s
896
+ facet_def = { "path" => path }
897
+
898
+ case config[:type]
899
+ when :string
900
+ facet_def["type"] = "string"
901
+ facet_def["numBuckets"] = config[:num_buckets] || 10
902
+ when :number
903
+ facet_def["type"] = "number"
904
+ facet_def["boundaries"] = config[:boundaries] if config[:boundaries]
905
+ facet_def["default"] = config[:default] if config[:default]
906
+ when :date
907
+ facet_def["type"] = "date"
908
+ facet_def["boundaries"] = config[:boundaries].map do |d|
909
+ d.respond_to?(:iso8601) ? d.iso8601 : d
910
+ end if config[:boundaries]
911
+ facet_def["default"] = config[:default] if config[:default]
912
+ end
913
+
914
+ definitions[name.to_s] = facet_def
915
+ end
916
+
917
+ definitions
918
+ end
919
+
920
+ def build_parse_object(doc, class_name)
921
+ # Try to use Parse::Object.build if available, otherwise return the hash
922
+ if defined?(Parse::Object) && Parse::Object.respond_to?(:build)
923
+ Parse::Object.build(doc, class_name)
924
+ else
925
+ # Fallback: return hash with class info
926
+ doc["className"] ||= class_name
927
+ doc
928
+ end
929
+ end
930
+
931
+ def process_search_results(raw_results, class_name, raw_mode)
932
+ sanitized_raw = sanitize_raw_results(raw_results)
933
+ if raw_mode?(raw_mode)
934
+ # The `raw:` channel is the only path callers see the un-
935
+ # converted Mongo shape on. Internal-fields denylist is
936
+ # ALWAYS stripped (cf. INTERNAL_FIELDS_DENYLIST) so a
937
+ # leaked `raw: true` parameter can't surface
938
+ # _hashed_password / _session_token. `raw_results:` on the
939
+ # returned SearchResult mirrors the sanitized form for the
940
+ # same reason.
941
+ SearchResult.new(results: sanitized_raw, raw_results: sanitized_raw)
942
+ else
943
+ parse_results = Parse::MongoDB.convert_documents_to_parse(raw_results, class_name)
944
+ objects = parse_results.each_with_index.map do |doc, idx|
945
+ obj = build_parse_object(doc, class_name)
946
+ raw_doc = raw_results[idx]
947
+ # Attach search metadata from original raw document (scores are stripped during conversion)
948
+ if obj && raw_doc["_score"]
949
+ obj.instance_variable_set(:@_search_score, raw_doc["_score"])
950
+ # Define accessor if not already defined
951
+ unless obj.respond_to?(:search_score)
952
+ obj.define_singleton_method(:search_score) { @_search_score }
953
+ end
954
+ end
955
+ if obj && raw_doc["_highlights"]
956
+ obj.instance_variable_set(:@_search_highlights, raw_doc["_highlights"])
957
+ unless obj.respond_to?(:search_highlights)
958
+ obj.define_singleton_method(:search_highlights) { @_search_highlights }
959
+ end
960
+ end
961
+ obj
962
+ end.compact
963
+ SearchResult.new(results: objects, raw_results: sanitized_raw)
964
+ end
965
+ end
966
+
967
+ # Coerce the `raw:` argument against the module-level
968
+ # {#allow_raw} switch. Returns `true` only when both the caller
969
+ # asked for raw mode AND the runtime permits it.
970
+ def raw_mode?(requested)
971
+ return false unless requested
972
+ @allow_raw.nil? ? default_allow_raw : @allow_raw
973
+ end
974
+
975
+ # Strip {Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST}
976
+ # entries from every document. Unconditional: even when
977
+ # `raw:`-mode is permitted, internal Parse Server columns are
978
+ # never legitimate to return to a search caller.
979
+ def sanitize_raw_results(docs)
980
+ Array(docs).map { |doc| Parse::PipelineSecurity.strip_internal_fields(doc) }
981
+ end
982
+ end
983
+
984
+ # Initialize defaults
985
+ @enabled = false
986
+ @default_index = "default"
987
+ @allow_raw = nil
988
+ @require_session_token = false
989
+ @session_cache_ttl = 3600
990
+ @role_cache_ttl = 120
991
+ @session_cache = Session::MemoryCache.new
992
+ @role_cache = Session::MemoryCache.new
993
+ @master_warned = false
994
+ end
995
+ end