parse-stack-next 5.5.4 → 5.5.5

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -6
  3. data/README.md +26 -13
  4. data/bin/parse-console +9 -1
  5. data/docs/TEST_SERVER.md +115 -238
  6. data/docs/mcp_guide.md +1 -1
  7. data/docs/mongodb_index_optimization_guide.md +3 -2
  8. data/docs/usage_guide.md +1 -1
  9. data/docs/yard-template/default/fulldoc/html/css/common.css +52 -9
  10. data/docs/yard-template/default/fulldoc/html/css/full_list.css +40 -13
  11. data/lib/parse/agent/constraint_translator.rb +18 -18
  12. data/lib/parse/agent/errors.rb +29 -7
  13. data/lib/parse/agent/metadata_dsl.rb +6 -6
  14. data/lib/parse/agent/tools.rb +250 -59
  15. data/lib/parse/agent.rb +42 -30
  16. data/lib/parse/api/aggregate.rb +3 -3
  17. data/lib/parse/api/cloud_functions.rb +19 -10
  18. data/lib/parse/api/objects.rb +8 -8
  19. data/lib/parse/api/users.rb +9 -9
  20. data/lib/parse/atlas_search/session.rb +34 -34
  21. data/lib/parse/atlas_search.rb +243 -110
  22. data/lib/parse/client/body_builder.rb +10 -10
  23. data/lib/parse/client/logging.rb +5 -2
  24. data/lib/parse/client/profiling.rb +5 -2
  25. data/lib/parse/client/protocol.rb +1 -1
  26. data/lib/parse/client/url_redaction.rb +94 -0
  27. data/lib/parse/client.rb +43 -28
  28. data/lib/parse/embeddings/image_fetch.rb +6 -1
  29. data/lib/parse/embeddings/voyage.rb +16 -17
  30. data/lib/parse/live_query/client.rb +7 -7
  31. data/lib/parse/live_query/subscription.rb +1 -1
  32. data/lib/parse/lock.rb +1 -1
  33. data/lib/parse/lock_backend.rb +118 -2
  34. data/lib/parse/model/acl.rb +24 -24
  35. data/lib/parse/model/classes/job_schedule.rb +8 -8
  36. data/lib/parse/model/classes/job_status.rb +9 -9
  37. data/lib/parse/model/classes/role.rb +49 -49
  38. data/lib/parse/model/classes/session.rb +2 -2
  39. data/lib/parse/model/classes/user.rb +66 -66
  40. data/lib/parse/model/core/builder.rb +7 -7
  41. data/lib/parse/model/core/create_lock.rb +1 -1
  42. data/lib/parse/model/core/properties.rb +4 -4
  43. data/lib/parse/model/file.rb +57 -16
  44. data/lib/parse/model/model.rb +19 -19
  45. data/lib/parse/model/object.rb +38 -38
  46. data/lib/parse/model/pointer.rb +4 -4
  47. data/lib/parse/model/push.rb +5 -5
  48. data/lib/parse/mongodb.rb +84 -26
  49. data/lib/parse/pipeline_security.rb +2 -2
  50. data/lib/parse/query/constraints.rb +38 -38
  51. data/lib/parse/query.rb +151 -75
  52. data/lib/parse/retrieval/reranker/cohere.rb +30 -0
  53. data/lib/parse/schema.rb +1 -1
  54. data/lib/parse/stack/version.rb +1 -1
  55. data/lib/parse/stack.rb +23 -10
  56. data/lib/parse/two_factor_auth/user_extension.rb +25 -25
  57. data/lib/parse/webhooks/payload.rb +35 -35
  58. data/lib/parse/webhooks/registration.rb +2 -2
  59. data/lib/parse/webhooks/replay_protection.rb +16 -16
  60. data/lib/parse/webhooks.rb +11 -11
  61. data/parse-stack-next.gemspec +19 -1
  62. metadata +2 -38
  63. data/.bundle/config +0 -5
  64. data/.env.sample +0 -138
  65. data/.env.test +0 -10
  66. data/.github/ISSUE_TEMPLATE/bug_report.yml +0 -105
  67. data/.github/ISSUE_TEMPLATE/feature_request.yml +0 -67
  68. data/.github/dependabot.yml +0 -13
  69. data/.github/workflows/codeql.yml +0 -44
  70. data/.github/workflows/docs.yml +0 -39
  71. data/.github/workflows/release.yml +0 -43
  72. data/.github/workflows/ruby.yml +0 -38
  73. data/.gitignore +0 -56
  74. data/.ruby-version +0 -1
  75. data/.solargraph.yml +0 -22
  76. data/.vscode/settings.json +0 -3
  77. data/.yardopts +0 -19
  78. data/Gemfile +0 -43
  79. data/Gemfile.lock +0 -198
  80. data/Makefile +0 -63
  81. data/Rakefile +0 -825
  82. data/config/parse-config.json +0 -12
  83. data/scripts/debug-ips.js +0 -35
  84. data/scripts/docker/Dockerfile.parse +0 -17
  85. data/scripts/docker/atlas-init.js +0 -284
  86. data/scripts/docker/docker-compose.atlas.yml +0 -80
  87. data/scripts/docker/docker-compose.test.yml +0 -159
  88. data/scripts/docker/docker-compose.verifyemail.yml +0 -4
  89. data/scripts/docker/mongo-init.js +0 -21
  90. data/scripts/docker/preflight.sh +0 -76
  91. data/scripts/eval_mcp_with_lm_studio.rb +0 -274
  92. data/scripts/start-parse.sh +0 -154
  93. data/scripts/start_mcp_server.rb +0 -78
  94. data/scripts/test_server_connection.rb +0 -82
  95. data/scripts/vector_prototype/create_vector_index.js +0 -105
  96. data/scripts/vector_prototype/fetch_embeddings.py +0 -241
  97. data/scripts/vector_prototype/fixture_manifest.json +0 -9
  98. data/scripts/vector_prototype/query_prototype.rb +0 -84
  99. data/scripts/vector_prototype/run.sh +0 -34
@@ -37,21 +37,21 @@ module Parse
37
37
  # Error raised for invalid search parameters
38
38
  class InvalidSearchParameters < StandardError; end
39
39
 
40
- # Error raised when the caller did not supply +session_token:+ or
41
- # +master: true+ and {.require_session_token} is +true+. Atlas
40
+ # Error raised when the caller did not supply `session_token:` or
41
+ # `master: true` and {.require_session_token} is `true`. Atlas
42
42
  # Search bypasses Parse Server's ACL evaluation, so the caller
43
43
  # must either pass a session token (so the SDK can inject a
44
- # +_rperm+ +$match+) or explicitly opt into master-key semantics.
44
+ # `_rperm` `$match`) or explicitly opt into master-key semantics.
45
45
  class ACLRequired < StandardError; end
46
46
 
47
- # Error raised when {.faceted_search} is called with a +session_token+.
48
- # +$searchMeta+ returns a single metadata document — bucket
47
+ # Error raised when {.faceted_search} is called with a `session_token`.
48
+ # `$searchMeta` returns a single metadata document — bucket
49
49
  # counts that include restricted documents and cannot be
50
- # post-filtered with +$match+ because the matched documents are
50
+ # post-filtered with `$match` because the matched documents are
51
51
  # not in the output stream. ACL-safe faceting requires the search
52
- # index to tokenize +_rperm+ and a +compound.filter+ injection
52
+ # index to tokenize `_rperm` and a `compound.filter` injection
53
53
  # path; both are deferred to a follow-up release. Callers that
54
- # need ACL-aware faceting today must either run with +master: true+
54
+ # need ACL-aware faceting today must either run with `master: true`
55
55
  # or implement post-aggregation filtering themselves.
56
56
  class FacetedSearchNotACLSafe < StandardError; end
57
57
 
@@ -79,23 +79,23 @@ module Parse
79
79
  attr_accessor :allow_raw
80
80
 
81
81
  # @!attribute [rw] require_session_token
82
- # When +true+, {.search}, {.autocomplete}, and
82
+ # When `true`, {.search}, {.autocomplete}, and
83
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
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
87
  # for missing-token calls, the same pattern used by
88
88
  # {Parse::Agent} for master-key construction.
89
89
  #
90
90
  # New deployments are strongly encouraged to flip this to
91
- # +true+ at startup. The next major release will flip the
91
+ # `true` at startup. The next major release will flip the
92
92
  # default.
93
93
  # @return [Boolean]
94
94
  attr_accessor :require_session_token
95
95
 
96
96
  # @!attribute [rw] session_cache_ttl
97
97
  # TTL (seconds) for {Session}'s session-token → user-id cache.
98
- # Default: 3600 (1 hour). Longer values reduce +/users/me+
98
+ # Default: 3600 (1 hour). Longer values reduce `/users/me`
99
99
  # round-trips but extend the window during which a revoked
100
100
  # session can still authenticate Atlas Search calls; apps
101
101
  # with sub-TTL revocation requirements should call
@@ -115,8 +115,8 @@ module Parse
115
115
  # @!attribute [rw] session_cache
116
116
  # Pluggable cache for {Session}'s session-token lookups.
117
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
118
+ # sharing; the object must respond to `get(key)`,
119
+ # `set(key, value, ttl:)`, and `invalidate(key)`. Defaults
120
120
  # to a process-local {Session::MemoryCache}.
121
121
  # @return [#get, #set, #invalidate]
122
122
  attr_accessor :session_cache
@@ -135,9 +135,9 @@ module Parse
135
135
  # (raw flag ignored) in production-like environments and
136
136
  # `true` when RACK_ENV/RAILS_ENV is `development` or `test`.
137
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+.
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
141
  # @param session_cache_ttl [Integer] session-token cache TTL
142
142
  # (seconds). Default: 3600.
143
143
  # @param role_cache_ttl [Integer] role-name cache TTL (seconds).
@@ -252,7 +252,7 @@ module Parse
252
252
  # @option options [String, Parse::Role] :acl_role act as the given role for
253
253
  # ACL evaluation (no REST equivalent; mongo-direct only).
254
254
  # @option options [Symbol] :read_preference MongoDB read preference applied
255
- # to the underlying collection (e.g. +:secondary+).
255
+ # to the underlying collection (e.g. `:secondary`).
256
256
  # @option options [Integer] :max_time_ms maximum server-side execution time
257
257
  # in milliseconds for the aggregate command.
258
258
  #
@@ -316,80 +316,77 @@ module Parse
316
316
  builder.with_highlight(path: options[:highlight_field])
317
317
  end
318
318
 
319
- # CRITICAL: $search MUST be stage 0 of an Atlas Search
320
- # pipeline. MongoDB Atlas rejects pipelines whose first stage
321
- # is anything other than $search/$searchMeta. Do NOT route
322
- # through Parse::MongoDB.aggregate here that helper prepends
323
- # the ACL $match to position 0, which Atlas would reject. We
324
- # build the pipeline manually with $search at index 0 and
325
- # place the ACL $match AFTER $search (which is correct: $search
326
- # has already produced its candidate set, the $match narrows it
327
- # to ACL-readable rows, then the caller filter narrows further).
328
- pipeline = [builder.build]
329
-
330
- # Add score projection
331
- pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }
332
-
333
- # Add highlights projection if requested
334
- if options[:highlight_field]
335
- pipeline << { "$addFields" => { "_highlights" => { "$meta" => "searchHighlights" } } }
336
- end
337
-
338
- # Inject ACL $match BEFORE the caller-supplied filter (but AFTER
339
- # $search and the $addFields stages) so the user-controlled
340
- # filter cannot exfiltrate restricted documents that passed the
341
- # $search operator. The $exists: false branch in `read_predicate`
342
- # covers documents Parse Server treats as public (no _rperm).
343
- unless resolution.master?
344
- acl_match = Parse::ACLScope.match_stage_for(resolution)
345
- pipeline << acl_match if acl_match
346
- end
347
-
348
- # Add filter stage if provided
349
- if options[:filter]
350
- mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name)
351
- pipeline << { "$match" => mongo_filter }
352
- end
319
+ # $search MUST be stage 0 of an Atlas Search pipeline (MongoDB
320
+ # rejects any other first stage), so we must NOT route through
321
+ # Parse::MongoDB.aggregate that helper prepends the ACL $match
322
+ # to position 0. Execution plus the full SDK-side enforcement
323
+ # chain (ACL $match placed AFTER $search, protectedFields strip,
324
+ # pointerFields filter, sub-doc redaction) lives in
325
+ # `search_pipeline!`, shared with the builder-block path
326
+ # (`search_with_stage` / Parse::Query#atlas_search).
327
+ search_pipeline!(
328
+ collection_name, builder.build,
329
+ resolution: resolution,
330
+ protected_fields: protected_fields,
331
+ pointer_fields: pointer_fields,
332
+ highlight_field: options[:highlight_field],
333
+ filter: options[:filter],
334
+ sort: options[:sort],
335
+ skip: skip_val,
336
+ limit: limit,
337
+ max_time_ms: options[:max_time_ms],
338
+ read_preference: read_preference,
339
+ class_name: options[:class_name] || collection_name,
340
+ raw: options[:raw],
341
+ )
342
+ end
353
343
 
354
- # Add sort (default by score)
355
- sort_spec = options[:sort] || { "_score" => -1 }
356
- pipeline << { "$sort" => sort_spec }
344
+ # Execute a caller-supplied `$search` stage (built via a
345
+ # {SearchBuilder}) with the same ACL/CLP/protectedFields/
346
+ # pointerFields enforcement as {.search}, keeping `$search` at
347
+ # stage 0. This is the entry point for Parse::Query#atlas_search's
348
+ # builder-block mode, which constructs its own `$search` stage
349
+ # instead of going through the query/fields/fuzzy options.
350
+ #
351
+ # Auth is resolved from `options` exactly like {.search}:
352
+ # `session_token:` / `master: true` / `acl_user:` / `acl_role:`
353
+ # (mutually exclusive). Without one, behavior follows
354
+ # `require_session_token` (public-only fallback, or `ACLRequired`).
355
+ #
356
+ # @param collection_name [String] the Parse collection name
357
+ # @param search_stage [Hash] a built `{ "$search" => {...} }` stage
358
+ # @param options [Hash] `:filter`, `:sort`, `:skip`, `:limit`,
359
+ # `:highlight_field`, `:max_time_ms`, `:read_preference`,
360
+ # `:class_name`, `:raw`, plus the auth kwargs above.
361
+ # @return [Parse::AtlasSearch::SearchResult]
362
+ def search_with_stage(collection_name, search_stage, **options)
363
+ require_available!
364
+ assert_search_stage_safe!(search_stage)
357
365
 
358
- # Add pagination
359
- pipeline << { "$skip" => skip_val } if skip_val > 0
360
- pipeline << { "$limit" => limit }
366
+ read_preference = options.delete(:read_preference)
367
+ resolution = resolve_scope!(options, method_name: :search)
368
+ assert_clp_find!(collection_name, resolution)
369
+ pointer_fields = resolve_pointer_fields!(collection_name, resolution)
370
+ protected_fields = Parse::CLPScope.protected_fields_for(
371
+ collection_name, resolution.permission_strings,
372
+ )
373
+ assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)
361
374
 
362
- # Execute directly against the MongoDB collection — bypasses
363
- # Parse::MongoDB.aggregate so its ACL-prepend doesn't violate
364
- # the $search-at-stage-0 invariant. We're reproducing the
365
- # SDK-side enforcement chain (ACL match, protectedFields strip,
366
- # pointerFields filter, embedded sub-doc redaction) inline below.
367
- raw_results = run_atlas_pipeline!(
368
- collection_name, pipeline, options[:max_time_ms],
375
+ search_pipeline!(
376
+ collection_name, search_stage,
377
+ resolution: resolution,
378
+ protected_fields: protected_fields,
379
+ pointer_fields: pointer_fields,
380
+ highlight_field: options[:highlight_field],
381
+ filter: options[:filter],
382
+ sort: options[:sort],
383
+ skip: options[:skip] || 0,
384
+ limit: options[:limit] || 100,
385
+ max_time_ms: options[:max_time_ms],
369
386
  read_preference: read_preference,
387
+ class_name: options[:class_name] || collection_name,
388
+ raw: options[:raw],
370
389
  )
371
-
372
- # Post-fetch enforcement: walk the result rows the same way
373
- # Parse::MongoDB.aggregate would. Master mode is the ACL bypass
374
- # — skip every redaction layer (matches the helper's behavior).
375
- unless resolution.master?
376
- Parse::ACLScope.redact_results!(raw_results, resolution)
377
- Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
378
- if pointer_fields
379
- raw_results = Parse::CLPScope.filter_by_pointer_fields(
380
- raw_results, pointer_fields, resolution.user_id,
381
- )
382
- end
383
- # ATLAS-4: drop any `_highlights` entry whose `path` names a
384
- # protected field. `searchHighlights` returns the matched
385
- # token plus its surrounding text, which would otherwise leak
386
- # the protected field's value through the snippet.
387
- strip_protected_highlights!(raw_results, protected_fields) if protected_fields.any?
388
- end
389
-
390
- # Convert results
391
- class_name = options[:class_name] || collection_name
392
- process_search_results(raw_results, class_name, options[:raw])
393
390
  end
394
391
 
395
392
  # Perform an autocomplete search for search-as-you-type functionality.
@@ -415,7 +412,7 @@ module Parse
415
412
  # @option options [String, Parse::Role] :acl_role act as the given role for
416
413
  # ACL evaluation (no REST equivalent; mongo-direct only).
417
414
  # @option options [Symbol] :read_preference MongoDB read preference applied
418
- # to the underlying collection (e.g. +:secondary+).
415
+ # to the underlying collection (e.g. `:secondary`).
419
416
  # @option options [Integer] :max_time_ms maximum server-side execution time
420
417
  # in milliseconds for the aggregate command.
421
418
  #
@@ -489,7 +486,7 @@ module Parse
489
486
 
490
487
  # Add filter if provided
491
488
  if options[:filter]
492
- mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name)
489
+ mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name, resolution: resolution)
493
490
  pipeline << { "$match" => mongo_filter }
494
491
  end
495
492
 
@@ -535,11 +532,11 @@ module Parse
535
532
  # @param query [String, nil] the search query text (nil for match-all)
536
533
  # @param facets [Hash] facet definitions
537
534
  # @param options [Hash] search options (same as {#search}; see that
538
- # method for the full list of accepted +@option+ entries including
539
- # +:index+, +:fields+, +:fuzzy+, +:limit+, +:filter+, +:read_preference+,
540
- # +:max_time_ms+, and the scoping kwargs +:master+, +:session_token+,
541
- # +:acl_user+, +:acl_role+). Note: scoped identity kwargs require
542
- # +master: true+ to be passed explicitly — $searchMeta bucket counts
535
+ # method for the full list of accepted `@option` entries including
536
+ # `:index`, `:fields`, `:fuzzy`, `:limit`, `:filter`, `:read_preference`,
537
+ # `:max_time_ms`, and the scoping kwargs `:master`, `:session_token`,
538
+ # `:acl_user`, `:acl_role`). Note: scoped identity kwargs require
539
+ # `master: true` to be passed explicitly — $searchMeta bucket counts
543
540
  # cannot be filtered by ACL after the fact, so the method refuses
544
541
  # to silently downgrade.
545
542
  #
@@ -681,6 +678,85 @@ module Parse
681
678
 
682
679
  private
683
680
 
681
+ # Execute a `$search`-first pipeline with the full SDK-side
682
+ # ACL/CLP enforcement chain, keeping `$search` at stage 0. Shared
683
+ # by {.search} (options API) and {.search_with_stage} (the
684
+ # builder-block path used by Parse::Query#atlas_search). The
685
+ # caller must have already resolved scope and pre-flighted the CLP
686
+ # `find` / pointerFields / protectedFields / highlight-field
687
+ # checks.
688
+ def search_pipeline!(collection_name, search_stage, resolution:,
689
+ protected_fields:, pointer_fields:,
690
+ highlight_field: nil, filter: nil, sort: nil,
691
+ skip: 0, limit: 100, max_time_ms: nil,
692
+ read_preference: nil, class_name: nil, raw: false)
693
+ # Backstop the stage-safety check at the shared execution chokepoint
694
+ # so ANY path that runs a $search stage (not just search_with_stage)
695
+ # rejects a non-$search stage / returnStoredSource. The .search and
696
+ # .autocomplete callers build safe stages via SearchBuilder, so this
697
+ # only ever fires for a caller-supplied stage.
698
+ assert_search_stage_safe!(search_stage)
699
+ pipeline = [search_stage]
700
+
701
+ # Score projection.
702
+ pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }
703
+
704
+ # Highlights projection if requested.
705
+ if highlight_field
706
+ pipeline << { "$addFields" => { "_highlights" => { "$meta" => "searchHighlights" } } }
707
+ end
708
+
709
+ # Inject ACL $match AFTER $search / $addFields but BEFORE the
710
+ # caller-supplied filter, so a user-controlled filter cannot
711
+ # exfiltrate restricted documents that passed the $search
712
+ # operator. The $exists: false branch in `read_predicate` covers
713
+ # documents Parse Server treats as public (no _rperm). Master
714
+ # mode is the ACL bypass — skip it (matches
715
+ # Parse::MongoDB.aggregate's behavior).
716
+ unless resolution.master?
717
+ acl_match = Parse::ACLScope.match_stage_for(resolution)
718
+ pipeline << acl_match if acl_match
719
+ end
720
+
721
+ # Caller-supplied filter, sanitized against operator injection
722
+ # AND protected-field oracle probing (via the resolution).
723
+ if filter
724
+ mongo_filter = convert_filter_for_mongodb(filter, collection_name, resolution: resolution)
725
+ pipeline << { "$match" => mongo_filter }
726
+ end
727
+
728
+ pipeline << { "$sort" => (sort || { "_score" => -1 }) }
729
+ pipeline << { "$skip" => skip } if skip.to_i > 0
730
+ pipeline << { "$limit" => limit }
731
+
732
+ # Execute directly against the collection — bypasses
733
+ # Parse::MongoDB.aggregate so its ACL-prepend doesn't violate the
734
+ # $search-at-stage-0 invariant. We reproduce the SDK-side
735
+ # enforcement chain inline below.
736
+ raw_results = run_atlas_pipeline!(
737
+ collection_name, pipeline, max_time_ms, read_preference: read_preference,
738
+ )
739
+
740
+ # Post-fetch enforcement: walk the result rows the same way
741
+ # Parse::MongoDB.aggregate would. Master mode skips every
742
+ # redaction layer.
743
+ unless resolution.master?
744
+ Parse::ACLScope.redact_results!(raw_results, resolution)
745
+ Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
746
+ if pointer_fields
747
+ raw_results = Parse::CLPScope.filter_by_pointer_fields(
748
+ raw_results, pointer_fields, resolution.user_id,
749
+ )
750
+ end
751
+ # ATLAS-4: drop any `_highlights` entry whose `path` names a
752
+ # protected field — the snippet would otherwise leak the
753
+ # protected value.
754
+ strip_protected_highlights!(raw_results, protected_fields) if protected_fields.any?
755
+ end
756
+
757
+ process_search_results(raw_results, class_name || collection_name, raw)
758
+ end
759
+
684
760
  def require_available!
685
761
  Parse::MongoDB.require_gem!
686
762
  unless available?
@@ -690,30 +766,30 @@ module Parse
690
766
  end
691
767
  end
692
768
 
693
- # Pop the auth-related kwargs (+:session_token+, +:master+,
694
- # +:acl_user+, +:acl_role+) off +options+ and return a fully
769
+ # Pop the auth-related kwargs (`:session_token`, `:master`,
770
+ # `:acl_user`, `:acl_role`) off `options` and return a fully
695
771
  # resolved {Parse::ACLScope::Resolution}. Replaces the old
696
- # +resolve_acl_options!+ shim that returned a bare Hash — the
772
+ # `resolve_acl_options!` shim that returned a bare Hash — the
697
773
  # post-fetch enforcement chain ({Parse::ACLScope.redact_results!},
698
774
  # {Parse::CLPScope.redact_protected_fields!}, etc.) all consume a
699
775
  # Resolution, so producing one here keeps the call sites uniform.
700
776
  #
701
777
  # Modes match {Parse::ACLScope::Resolution}:
702
778
  #
703
- # * +:session++session_token:+ resolved, or +acl_user:+ /
704
- # +acl_role:+ supplied. ACL+CLP+protectedFields enforcement
779
+ # * `:session``session_token:` resolved, or `acl_user:` /
780
+ # `acl_role:` supplied. ACL+CLP+protectedFields enforcement
705
781
  # runs in full.
706
- # * +:master++master: true+. ACL/CLP enforcement is bypassed
782
+ # * `:master``master: true`. ACL/CLP enforcement is bypassed
707
783
  # (the caller has explicit master-key intent).
708
- # * +:public+ — no scope kwargs supplied, +require_session_token+
709
- # is +false+. A one-time banner is emitted and the call
784
+ # * `:public` — no scope kwargs supplied, `require_session_token`
785
+ # is `false`. A one-time banner is emitted and the call
710
786
  # falls through with public-only ACL semantics — public-mode
711
787
  # enforcement still runs (refused rows are filtered, the
712
788
  # CLP allowlist is consulted), the perms set is just
713
- # +["*"]+ rather than user-scoped.
789
+ # `["*"]` rather than user-scoped.
714
790
  #
715
791
  # Raises {ACLRequired} when no scope kwargs are supplied and
716
- # {.require_session_token} is +true+. The agent-tool path
792
+ # {.require_session_token} is `true`. The agent-tool path
717
793
  # refuses unconditionally regardless of this toggle — see
718
794
  # {Parse::Agent::Tools}.
719
795
  def resolve_scope!(options, method_name:)
@@ -880,9 +956,9 @@ module Parse
880
956
  raise
881
957
  end
882
958
 
883
- # Emit a one-time +[Parse::AtlasSearch:SECURITY]+ banner the
959
+ # Emit a one-time `[Parse::AtlasSearch:SECURITY]` banner the
884
960
  # first time an Atlas Search call runs without a session_token
885
- # and without an explicit +master: true+. Mirrors the
961
+ # and without an explicit `master: true`. Mirrors the
886
962
  # warned-once pattern {Parse::Agent} uses for master-key
887
963
  # construction so noisy logs don't drown out the warning, but
888
964
  # one log line per process is enough to surface the misuse to
@@ -909,14 +985,71 @@ module Parse
909
985
  Array(fields).map(&:to_s)
910
986
  end
911
987
 
912
- def convert_filter_for_mongodb(filter, collection_name)
988
+ # Validate a caller-supplied `$search` stage before it is executed
989
+ # by {.search_with_stage}. The SDK's own {SearchBuilder} always emits
990
+ # a safe stage, but `search_with_stage` is public and a caller can
991
+ # hand-roll one — so we refuse the shapes that would silently defeat
992
+ # ACL enforcement.
993
+ #
994
+ # The load-bearing check is `returnStoredSource`: with stored-source
995
+ # projection Atlas returns ONLY the index-stored fields. If `_rperm`
996
+ # is not among them, the post-`$search` ACL `$match` (and the
997
+ # post-fetch redaction) treat the row as public — `_rperm` absent is
998
+ # interpreted as "no restriction" — so an ACL-restricted document's
999
+ # stored fields leak. There is no per-document rehydration on this
1000
+ # path, so the only safe answer is to reject the flag outright.
1001
+ #
1002
+ # @param search_stage [Hash] the `{ "$search" => {...} }` stage.
1003
+ # @raise [InvalidSearchParameters] on a non-$search or unsafe stage.
1004
+ def assert_search_stage_safe!(search_stage)
1005
+ unless search_stage.is_a?(Hash)
1006
+ raise InvalidSearchParameters,
1007
+ "search_stage must be a Hash containing a $search stage"
1008
+ end
1009
+ body = search_stage["$search"] || search_stage[:"$search"]
1010
+ if body.nil?
1011
+ raise InvalidSearchParameters,
1012
+ "search_stage must contain a $search stage (build it with a SearchBuilder)"
1013
+ end
1014
+ if body.is_a?(Hash)
1015
+ stored = body["returnStoredSource"] || body[:returnStoredSource]
1016
+ if stored
1017
+ raise InvalidSearchParameters,
1018
+ "returnStoredSource is not permitted in a $search stage passed to " \
1019
+ "search_with_stage: Atlas stored-source projection can omit _rperm, " \
1020
+ "which would defeat ACL enforcement on the returned documents."
1021
+ end
1022
+ end
1023
+ search_stage
1024
+ end
1025
+
1026
+ def convert_filter_for_mongodb(filter, collection_name, resolution: nil)
1027
+ return filter unless filter
1028
+
913
1029
  # The filter hash is interpolated directly into a `$match` stage in
914
1030
  # the search pipeline. A caller forwarding a user-controlled filter
915
1031
  # (search UI, autocomplete endpoint) must not be able to inject
916
1032
  # `$where`, `$function`, `$accumulator`, `$out`, or `$merge` here.
917
1033
  # `Parse::PipelineSecurity.validate_filter!` recurses through the
918
1034
  # hash and refuses any of those operators at any depth.
919
- Parse::PipelineSecurity.validate_filter!(filter) if filter
1035
+ Parse::PipelineSecurity.validate_filter!(filter)
1036
+
1037
+ # Additionally refuse `$expr`-based references to protected fields.
1038
+ # `validate_filter!` blocks the code-execution operators above but
1039
+ # NOT `$expr`, which a scoped caller could use to binary-search a
1040
+ # protectedFields value (`{"$expr" => {"$gt" => ["$ssn", "x"]}}`)
1041
+ # even though the field is stripped from the OUTPUT. This is the
1042
+ # same oracle guard the mongo-direct aggregate path applies; the
1043
+ # `filter:` path previously skipped it. `refuse_protected_field_-
1044
+ # references!` self-skips on master / nil resolution / empty
1045
+ # protectedFields, so it's safe to call unconditionally when a
1046
+ # resolution is supplied.
1047
+ if resolution
1048
+ Parse::PipelineSecurity.refuse_protected_field_references!(
1049
+ [{ "$match" => filter }], collection_name, resolution,
1050
+ )
1051
+ end
1052
+
920
1053
  filter
921
1054
  end
922
1055
 
@@ -122,16 +122,16 @@ module Parse
122
122
  #
123
123
  # 1. **Structural pass.** If the body (after whitespace trim) parses as
124
124
  # JSON, the parsed structure is walked recursively. Any value whose
125
- # key matches +SENSITIVE_FIELDS_SET+ (case-insensitive) is replaced.
125
+ # key matches `SENSITIVE_FIELDS_SET` (case-insensitive) is replaced.
126
126
  # String values that themselves look like JSON are recursively
127
- # parsed and scrubbed — catches +{"body":"{\"password\":\"x\"}"}+
127
+ # parsed and scrubbed — catches `{"body":"{\"password\":\"x\"}"}`
128
128
  # payloads.
129
129
  #
130
130
  # 2. **Regex pass.** The result of the structural pass (or the original
131
131
  # string if parsing failed) is always also run through the
132
- # +SENSITIVE_PATTERN+ regex as defense-in-depth. This catches form-
132
+ # `SENSITIVE_PATTERN` regex as defense-in-depth. This catches form-
133
133
  # encoded bodies, partial JSON, escaped-quote payloads, and string
134
- # array elements like +["password=hunter2"]+ that the structural
134
+ # array elements like `["password=hunter2"]` that the structural
135
135
  # walker can't redact in-place.
136
136
  # @param str [String] the string to redact.
137
137
  # @return [String] the redacted string.
@@ -153,9 +153,9 @@ module Parse
153
153
  sep_part = $2
154
154
  val_part = $3
155
155
  # Skip values that the structural pass already redacted —
156
- # otherwise the regex value-class +[^"&\s,}\]]+ stops at the
157
- # bracket and we end up with +[FILTERED]]+ from the trailing
158
- # close-bracket left over from +"[FILTERED]"+.
156
+ # otherwise the regex value-class `[^"&\s,}\]]+` stops at the
157
+ # bracket and we end up with `[FILTERED]]` from the trailing
158
+ # close-bracket left over from `"[FILTERED]"`.
159
159
  if val_part == "[FILTERED" || val_part == REDACTED_PLACEHOLDER
160
160
  "#{key_part}#{sep_part}#{val_part}"
161
161
  else
@@ -184,7 +184,7 @@ module Parse
184
184
  #
185
185
  # When a value is itself a String that looks like JSON, attempt to
186
186
  # parse-scrub-re-encode it so embedded-JSON payloads are also covered
187
- # (e.g. +{"body":"{\"password\":\"x\"}"}+).
187
+ # (e.g. `{"body":"{\"password\":\"x\"}"}`).
188
188
  def self.scrub_sensitive!(node)
189
189
  case node
190
190
  when Hash
@@ -213,7 +213,7 @@ module Parse
213
213
 
214
214
  # @!visibility private
215
215
  # Recursively walk a parsed JSON structure replacing any
216
- # numeric-only Array of length >= +LOG_VECTOR_COMPACT_THRESHOLD+
216
+ # numeric-only Array of length >= `LOG_VECTOR_COMPACT_THRESHOLD`
217
217
  # with a compact placeholder string ("<vector dims=N>"). Mutates
218
218
  # Hashes/Arrays in place; returns the node for chaining. Distinct
219
219
  # pass from {scrub_sensitive!} because the criterion is shape
@@ -259,7 +259,7 @@ module Parse
259
259
  end
260
260
 
261
261
  # @!visibility private
262
- # If +str+ parses as JSON (object or array), scrub structurally and
262
+ # If `str` parses as JSON (object or array), scrub structurally and
263
263
  # re-encode. Otherwise return the original string unchanged.
264
264
  def self.maybe_scrub_embedded_json(str)
265
265
  return str unless (inner = try_parse_json(str))
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "faraday"
5
5
  require "logger"
6
+ require_relative "url_redaction"
6
7
 
7
8
  module Parse
8
9
  module Middleware
@@ -227,8 +228,10 @@ module Parse
227
228
  end
228
229
 
229
230
  def sanitize_url(url)
230
- # Remove sensitive query parameters from logged URLs
231
- url.gsub(/([?&])(sessionToken|masterKey|apiKey)=[^&]*/, '\1\2=[FILTERED]')
231
+ # Redact credential-bearing query params from logged URLs. Shared
232
+ # with the profiling middleware via Parse::Middleware::URLRedaction
233
+ # so the two sanitizers can't drift.
234
+ Parse::Middleware::URLRedaction.sanitize(url)
232
235
  end
233
236
  end
234
237
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "faraday"
5
+ require_relative "url_redaction"
5
6
 
6
7
  module Parse
7
8
  module Middleware
@@ -120,8 +121,10 @@ module Parse
120
121
  private
121
122
 
122
123
  def sanitize_url(url)
123
- # Remove sensitive query parameters
124
- url.gsub(/([?&])(sessionToken|masterKey|apiKey)=[^&]*/, '\1\2=[FILTERED]')
124
+ # Redact credential-bearing query params. Shared with the logging
125
+ # middleware via Parse::Middleware::URLRedaction so the two
126
+ # sanitizers can't drift (F14 + its profiling twin).
127
+ Parse::Middleware::URLRedaction.sanitize(url)
125
128
  end
126
129
 
127
130
  def response_body_size(response_env)
@@ -33,7 +33,7 @@ module Parse
33
33
  READ_PREFERENCE = "X-Parse-Read-Preference"
34
34
  # The request header field for threading a caller-supplied context object
35
35
  # through a write or cloud-function call. Parse Server maps this header to
36
- # +req.info.context+ and flows it through beforeSave/afterSave triggers.
36
+ # `req.info.context` and flows it through beforeSave/afterSave triggers.
37
37
  CLOUD_CONTEXT = "X-Parse-Cloud-Context"
38
38
 
39
39
  # Valid read preference values for MongoDB