parse-stack-next 5.5.3 → 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 +13 -4
  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
data/lib/parse/query.rb CHANGED
@@ -19,7 +19,7 @@ module Parse
19
19
  # your Parse collections by utilizing the {http://docs.parseplatform.org/rest/guide/#queries
20
20
  # REST Querying interface}. This is the main engine behind making Parse queries
21
21
  # on remote collections. It takes a set of constraints and generates the
22
- # proper hash parameters that are passed to an API request in order to retrive
22
+ # proper hash parameters that are passed to an API request in order to retrieve
23
23
  # matching results. The querying design pattern is inspired from
24
24
  # {http://datamapper.org/ DataMapper} where symbols are overloaded with
25
25
  # specific methods with attached values.
@@ -44,19 +44,19 @@ module Parse
44
44
  # names when compiling the query. This feature can be overridden by changing the
45
45
  # value of {Parse::Query.field_formatter}.
46
46
  #
47
- # # default uses :columnize
48
- # query = Parse::User.query :field_one => 1, :FieldTwo => 2, :Field_Three => 3
49
- # query.compile_where # => {"fieldOne"=>1, "fieldTwo"=>2, "fieldThree"=>3}
47
+ # # default uses :columnize
48
+ # query = Parse::User.query :field_one => 1, :FieldTwo => 2, :Field_Three => 3
49
+ # query.compile_where # => {"fieldOne"=>1, "fieldTwo"=>2, "fieldThree"=>3}
50
50
  #
51
- # # turn off
52
- # Parse::Query.field_formatter = nil
53
- # query = Parse::User.query :field_one => 1, :FieldTwo => 2, :Field_Three => 3
54
- # query.compile_where # => {"field_one"=>1, "FieldTwo"=>2, "Field_Three"=>3}
51
+ # # turn off
52
+ # Parse::Query.field_formatter = nil
53
+ # query = Parse::User.query :field_one => 1, :FieldTwo => 2, :Field_Three => 3
54
+ # query.compile_where # => {"field_one"=>1, "FieldTwo"=>2, "Field_Three"=>3}
55
55
  #
56
- # # force everything camel case
57
- # Parse::Query.field_formatter = :camelize
58
- # query = Parse::User.query :field_one => 1, :FieldTwo => 2, :Field_Three => 3
59
- # query.compile_where # => {"FieldOne"=>1, "FieldTwo"=>2, "FieldThree"=>3}
56
+ # # force everything camel case
57
+ # Parse::Query.field_formatter = :camelize
58
+ # query = Parse::User.query :field_one => 1, :FieldTwo => 2, :Field_Three => 3
59
+ # query.compile_where # => {"FieldOne"=>1, "FieldTwo"=>2, "FieldThree"=>3}
60
60
  #
61
61
  # Most of the constraints supported by Parse are available to `Parse::Query`.
62
62
  # Assuming you have a column named `field`, here are some examples. For an
@@ -524,6 +524,25 @@ module Parse
524
524
  # Normalize to symbol for comparison (handles both string and symbol keys)
525
525
  expr_sym = expression.respond_to?(:to_sym) ? expression.to_sym : expression
526
526
 
527
+ # SEC-06: privilege/auth control options must NOT be settable from an
528
+ # untrusted, string-keyed conditions hash. `use_master_key` via a
529
+ # forwarded params hash (e.g. `Query.new(Cls, {"use_master_key" =>
530
+ # true, "ownerId" => "victim"})`) is an ACL/CLP-bypass mass-
531
+ # assignment; `session` swaps the query's auth principal. Honor these
532
+ # two ONLY when the caller passed a Symbol key (code-authored). A
533
+ # string form is treated as an ordinary field constraint — fail-safe:
534
+ # it matches a literal field of that name and never elevates.
535
+ if !expression.is_a?(Symbol) && (expr_sym == :use_master_key || expr_sym == :session)
536
+ warn "[Parse::Query] Ignoring string-keyed control option " \
537
+ "#{expression.inspect}; auth/privilege options (:use_master_key, " \
538
+ ":session) are honored only as symbol keys to prevent " \
539
+ "mass-assignment from untrusted params. Treating it as a field " \
540
+ "constraint. Set it via the query method (e.g. `use_master_key: " \
541
+ "true` as a symbol) if you intended to control authentication."
542
+ add_constraint(expression, value)
543
+ next
544
+ end
545
+
527
546
  if expr_sym == :order
528
547
  order value
529
548
  elsif expr_sym == :keys
@@ -635,23 +654,23 @@ module Parse
635
654
  # When set, Parse Server excludes the named fields from each returned
636
655
  # object, complementing the {#keys} allowlist. The two options can be
637
656
  # combined: Parse Server first applies the {#keys} allowlist, then
638
- # strips any field names listed in +excludeKeys+.
657
+ # strips any field names listed in `excludeKeys`.
639
658
  #
640
- # @note On the REST query path (+encode: true+ in {#compile}) this maps to
641
- # Parse Server's path-scoped +excludeKeys+. On the mongo-direct path
642
- # (explicit +.results_direct+, an auto-route, or an aggregation that
643
- # auto-promotes — e.g. an +$inQuery+ pointer constraint that rewrites to
644
- # a +$lookup+) the pipeline can only project the {#keys} allowlist, so
659
+ # @note On the REST query path (`encode: true` in {#compile}) this maps to
660
+ # Parse Server's path-scoped `excludeKeys`. On the mongo-direct path
661
+ # (explicit `.results_direct`, an auto-route, or an aggregation that
662
+ # auto-promotes — e.g. an `$inQuery` pointer constraint that rewrites to
663
+ # a `$lookup`) the pipeline can only project the {#keys} allowlist, so
645
664
  # the SDK honors the denylist as a post-fetch sanitize over the returned
646
665
  # results instead. That mongo-direct sanitize is recursive by name: it
647
666
  # strips EVERY key with a matching name at any depth, so excluding a
648
667
  # field also removes a same-named field inside included/nested objects —
649
668
  # broader than the REST path's top-level/dotted scoping. Reserved
650
- # envelope fields (+objectId+, +className+, +__type+, +createdAt+,
651
- # +updatedAt+, +ACL+ and their Mongo storage-form names) are never
669
+ # envelope fields (`objectId`, `className`, `__type`, `createdAt`,
670
+ # `updatedAt`, `ACL` and their Mongo storage-form names) are never
652
671
  # stripped, so object reconstruction is unaffected. The raw aggregation
653
672
  # accessor (`aggregate(...).raw`) returns unredacted documents — the
654
- # sanitize applies to the object/decoded result paths. +excludeKeys+ is a
673
+ # sanitize applies to the object/decoded result paths. `excludeKeys` is a
655
674
  # projection convenience, not an ACL/CLP boundary, so it does not affect
656
675
  # access control.
657
676
  #
@@ -731,7 +750,7 @@ module Parse
731
750
  # Song.all :order => { :like_count => :desc, :name => :asc }
732
751
  # @param ordering [Parse::Order, Symbol, String, Hash] one or more
733
752
  # ordering directives. A Hash maps field => direction. Unsupported
734
- # argument types raise +ArgumentError+ rather than being silently
753
+ # argument types raise `ArgumentError` rather than being silently
735
754
  # dropped.
736
755
  # @return [self]
737
756
  def order(*ordering)
@@ -808,9 +827,9 @@ module Parse
808
827
  # Song.all :limit => 2025 # large limits supported.
809
828
  # Song.all :limit => :max # as many records as possible.
810
829
  # @param count [Integer,Symbol,String,nil] The number of records to return.
811
- # Pass +:max+ to fetch as many records as possible (Parse-Server dependent).
812
- # Numeric strings (e.g. +"50"+) are coerced to Integer. Pass +nil+ to
813
- # explicitly clear the limit. Any other value raises +ArgumentError+
830
+ # Pass `:max` to fetch as many records as possible (Parse-Server dependent).
831
+ # Numeric strings (e.g. `"50"`) are coerced to Integer. Pass `nil` to
832
+ # explicitly clear the limit. Any other value raises `ArgumentError`
814
833
  # rather than silently disabling the limit.
815
834
  # @return [self]
816
835
  def limit(count)
@@ -855,18 +874,18 @@ module Parse
855
874
  # Forces Parse Server (and the underlying MongoDB driver) to use the
856
875
  # named index instead of the query planner's choice. Useful for
857
876
  # benchmarking or for working around sub-optimal plan selection.
858
- # The hint is emitted in the compiled REST query body as the +hint+
877
+ # The hint is emitted in the compiled REST query body as the `hint`
859
878
  # parameter (supported by Parse Server 7.4.0+) AND forwarded to the
860
- # mongo-direct path — +results_direct+ / +count_direct+ / +distinct_direct+
879
+ # mongo-direct path — `results_direct` / `count_direct` / `distinct_direct`
861
880
  # pass it to {Parse::MongoDB.aggregate}/{Parse::MongoDB.find} as the Mongo
862
- # +hint+ option, so a plan diagnosed with {#explain} can be corrected on
881
+ # `hint` option, so a plan diagnosed with {#explain} can be corrected on
863
882
  # either path.
864
883
  #
865
884
  # @example Force a specific index
866
885
  # Post.query(:status => "published").hint("status_1_created_at_-1").results
867
886
  #
868
887
  # @param index_name [String, nil, :_read_] the index name or key pattern to use,
869
- # or +nil+ to clear a previously set hint. Called with no arguments acts as a
888
+ # or `nil` to clear a previously set hint. Called with no arguments acts as a
870
889
  # reader and returns the current hint value.
871
890
  # @return [String, nil, self]
872
891
  HINT_UNSET = :_hint_unset_ # @!visibility private
@@ -1029,7 +1048,7 @@ module Parse
1029
1048
  # # parts of a single where constraint
1030
1049
  # { :column.constraint => value }
1031
1050
  # @see Parse::Constraint
1032
- # @param conditions [Hash] a set of constraints for this query.
1051
+ # @param expressions [Hash] a set of constraints for this query.
1033
1052
  # @param opts [Hash] a set of options when adding the constraints. This is
1034
1053
  # specific for each Parse::Constraint.
1035
1054
  # @return [self]
@@ -1669,7 +1688,7 @@ module Parse
1669
1688
  _id _created_at _updated_at _acl
1670
1689
  ].freeze
1671
1690
 
1672
- # Recursively delete every key named in +names+ from a nested
1691
+ # Recursively delete every key named in `names` from a nested
1673
1692
  # Hash/Array structure, in place. Symbol and string keys both match.
1674
1693
  # @param value [Object] a Hash, Array, or scalar
1675
1694
  # @param names [Set<String>] the key names to drop
@@ -2079,6 +2098,39 @@ module Parse
2079
2098
  end
2080
2099
  end
2081
2100
 
2101
+ # Auth kwargs for the Atlas Search bridge (`#atlas_search` builder
2102
+ # block). Explicit `atlas_search(...)` auth kwargs win; otherwise
2103
+ # derive from the query's own scope (`#scope_to_user`, an explicit
2104
+ # `session_token`, an ambient `Parse.with_session`, or `use_master_key`).
2105
+ #
2106
+ # Unlike {#mongo_direct_auth_kwargs} there is deliberately NO silent
2107
+ # `{ master: true }` fallback for an unscoped query — we return `{}`
2108
+ # so `Parse::AtlasSearch.search_with_stage` applies Atlas Search's own
2109
+ # policy (`resolve_scope!`): public-only fallback, or `ACLRequired`
2110
+ # when `Parse::AtlasSearch.require_session_token` is on. That keeps the
2111
+ # search bridge fail-closed rather than defaulting an unscoped block
2112
+ # search to a master-key ACL bypass.
2113
+ # @return [Hash] zero-or-one-of { session_token:/master:/acl_user:/acl_role: }
2114
+ # @!visibility private
2115
+ def atlas_search_auth_kwargs(options)
2116
+ explicit = %i[session_token master acl_user acl_role].select { |k| options.key?(k) }
2117
+ return explicit.to_h { |k| [k, options[k]] } if explicit.any?
2118
+
2119
+ if @acl_user
2120
+ { acl_user: @acl_user }
2121
+ elsif @acl_role
2122
+ { acl_role: @acl_role }
2123
+ elsif @session_token.is_a?(String) && !@session_token.empty?
2124
+ { session_token: @session_token }
2125
+ elsif use_master_key == true
2126
+ { master: true }
2127
+ elsif (ambient = ambient_session_token)
2128
+ { session_token: ambient }
2129
+ else
2130
+ {}
2131
+ end
2132
+ end
2133
+
2082
2134
  # The fiber-local ambient session token set by `Parse.with_session`,
2083
2135
  # or nil. A whitespace-only ambient is treated as absent so it cannot
2084
2136
  # block the master fallback and then fail a later presence check —
@@ -2140,7 +2192,7 @@ module Parse
2140
2192
  # instead of Parse::Object instances (default: false)
2141
2193
  # @param max_time_ms [Integer, nil] optional server-side time limit in milliseconds.
2142
2194
  # When provided, MongoDB will cancel the aggregation if it exceeds this budget and
2143
- # {Parse::MongoDB::ExecutionTimeout} is raised. Pass +nil+ (the default) for no cap.
2195
+ # {Parse::MongoDB::ExecutionTimeout} is raised. Pass `nil` (the default) for no cap.
2144
2196
  # @yield a block to iterate for each object that matched the query
2145
2197
  # @return [Array<Parse::Object>] if raw is false, a list of Parse::Object subclasses
2146
2198
  # @return [Array<Hash>] if raw is true, Parse-formatted JSON hashes
@@ -2180,7 +2232,7 @@ module Parse
2180
2232
 
2181
2233
  # Execute the aggregation directly on MongoDB. The pipeline was built
2182
2234
  # entirely from SDK constraint translation (no user-supplied stages),
2183
- # so legitimate +_rperm+/+_wperm+ references emitted by
2235
+ # so legitimate `_rperm`/`_wperm` references emitted by
2184
2236
  # {#readable_by_role} and friends are sanctioned. The DENIED_OPERATORS
2185
2237
  # walk still runs at the MongoDB layer. When `session_token:` or
2186
2238
  # `master:` is supplied, Parse::MongoDB.aggregate adds the
@@ -2491,55 +2543,73 @@ module Parse
2491
2543
  skip_val = options[:skip] || (@skip > 0 ? @skip : 0)
2492
2544
 
2493
2545
  if block_given?
2494
- # Builder block mode
2546
+ # Builder-block mode: the caller configures the $search stage
2547
+ # directly via the yielded SearchBuilder. Route through
2548
+ # Parse::AtlasSearch.search_with_stage so the SAME SDK-side
2549
+ # enforcement chain as the options API runs — scope resolution,
2550
+ # CLP `find`, the ACL $match placed AFTER $search (not prepended
2551
+ # to stage 0), protectedFields/pointerFields redaction, and the
2552
+ # $expr protected-field oracle guard on the caller filter.
2553
+ #
2554
+ # The previous implementation called Parse::MongoDB.aggregate
2555
+ # with `allow_internal_fields: true` and NO auth forwarding: it
2556
+ # both ran unscoped (resolved to :public / master, no ACL) and,
2557
+ # because that helper prepends the ACL $match at position 0, was
2558
+ # rejected outright by Atlas (which requires $search at stage 0).
2559
+ # It was non-functional under both supported security configs.
2495
2560
  index_name = options[:index] || Parse::AtlasSearch.default_index
2496
2561
  builder = Parse::AtlasSearch::SearchBuilder.new(index_name: index_name)
2497
2562
  yield builder
2498
2563
 
2499
- # Build pipeline: $search must be first
2500
- pipeline = [builder.build]
2501
-
2502
- # Add score projection
2503
- pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }
2504
-
2505
- # Add existing query constraints as $match
2564
+ # Carry the query's existing constraints as the caller filter,
2565
+ # interpolated into a post-$search $match. The constraints MUST be
2566
+ # converted to Mongo storage form first: `compile_where` yields the
2567
+ # Parse REST shape (pointer keys like `owner`, values that are live
2568
+ # Parse::Pointer objects / Parse date dicts). Fed to a `$match`
2569
+ # verbatim, a Parse::Pointer fails BSON serialization and dates/ids
2570
+ # target the wrong field (`owner` vs `_p_owner`). `convert_-
2571
+ # constraints_for_direct_mongodb` rewrites keys/values to storage
2572
+ # form — the same conversion the mongo-direct path applies, and the
2573
+ # one the pre-5.5.5 block path used before this branch was rewritten.
2574
+ filter = options[:filter]
2506
2575
  compiled_where = compile_where
2507
2576
  if compiled_where.present?
2508
2577
  regular_constraints = compiled_where.reject { |f, _| f == "__aggregation_pipeline" }
2509
2578
  if regular_constraints.any?
2510
2579
  mongo_constraints = convert_constraints_for_direct_mongodb(regular_constraints)
2511
- pipeline << { "$match" => mongo_constraints }
2580
+ filter = (filter || {}).merge(mongo_constraints)
2512
2581
  end
2513
2582
  end
2514
2583
 
2515
- # Add sort, skip, limit
2516
- pipeline << { "$sort" => { "_score" => -1 } }
2517
- pipeline << { "$skip" => skip_val } if skip_val > 0
2518
- pipeline << { "$limit" => limit }
2519
-
2520
- # SDK-built pipeline only — see results_direct for rationale.
2521
- raw_results = Parse::MongoDB.aggregate(@table, pipeline,
2522
- allow_internal_fields: true,
2523
- read_preference: @read_preference,
2524
- hint: @hint)
2584
+ search_opts = {
2585
+ filter: filter,
2586
+ limit: limit,
2587
+ skip: skip_val,
2588
+ class_name: @table,
2589
+ raw: options[:raw],
2590
+ **atlas_search_auth_kwargs(options),
2591
+ }
2592
+ rp = options.key?(:read_preference) ? options[:read_preference] : @read_preference
2593
+ search_opts[:read_preference] = rp unless rp.nil?
2594
+ search_opts[:max_time_ms] = options[:max_time_ms] if options.key?(:max_time_ms)
2525
2595
 
2526
- # Convert results
2527
- if options[:raw]
2528
- Parse::AtlasSearch::SearchResult.new(results: raw_results, raw_results: raw_results)
2529
- else
2530
- parse_results = Parse::MongoDB.convert_documents_to_parse(raw_results, @table)
2531
- objects = parse_results.map { |doc| Parse.decode(doc) }.compact
2532
- Parse::AtlasSearch::SearchResult.new(results: objects, raw_results: raw_results)
2533
- end
2596
+ Parse::AtlasSearch.search_with_stage(@table, builder.build, **search_opts)
2534
2597
  else
2535
2598
  # Simple options API - delegate to AtlasSearch module
2536
2599
  raise ArgumentError, "query string is required when not using a block" if query.nil?
2537
2600
 
2538
- # Merge query constraints as filter
2601
+ # Merge query constraints as filter. Convert to Mongo storage form
2602
+ # first (same reasoning as the block branch above): compile_where
2603
+ # yields the Parse REST shape (live Parse::Pointer objects, Parse
2604
+ # date dicts, un-prefixed pointer field names) which a raw $match
2605
+ # cannot serialize / would target the wrong column.
2539
2606
  compiled_where = compile_where
2540
2607
  if compiled_where.present?
2541
2608
  regular_constraints = compiled_where.reject { |f, _| f == "__aggregation_pipeline" }
2542
- options[:filter] = (options[:filter] || {}).merge(regular_constraints) if regular_constraints.any?
2609
+ if regular_constraints.any?
2610
+ mongo_constraints = convert_constraints_for_direct_mongodb(regular_constraints)
2611
+ options[:filter] = (options[:filter] || {}).merge(mongo_constraints)
2612
+ end
2543
2613
  end
2544
2614
 
2545
2615
  options[:class_name] = @table
@@ -2596,11 +2666,17 @@ module Parse
2596
2666
  "Call Parse::AtlasSearch.configure(enabled: true) after configuring Parse::MongoDB."
2597
2667
  end
2598
2668
 
2599
- # Merge query constraints as filter
2669
+ # Merge query constraints as filter, converted to Mongo storage form
2670
+ # first (see #atlas_search for the reasoning — a raw Parse::Pointer
2671
+ # fails BSON serialization and un-prefixed pointer fields target the
2672
+ # wrong column).
2600
2673
  compiled_where = compile_where
2601
2674
  if compiled_where.present?
2602
2675
  regular_constraints = compiled_where.reject { |f, _| f == "__aggregation_pipeline" }
2603
- options[:filter] = (options[:filter] || {}).merge(regular_constraints) if regular_constraints.any?
2676
+ if regular_constraints.any?
2677
+ mongo_constraints = convert_constraints_for_direct_mongodb(regular_constraints)
2678
+ options[:filter] = (options[:filter] || {}).merge(mongo_constraints)
2679
+ end
2604
2680
  end
2605
2681
 
2606
2682
  # Use query limit if set and no explicit limit provided
@@ -5964,22 +6040,22 @@ module Parse
5964
6040
  # @param verbose [Boolean, nil] whether to print verbose output (nil means use query's setting)
5965
6041
  # @param mongo_direct [Boolean] if true, uses MongoDB directly bypassing Parse Server (required for $literal)
5966
6042
  # @param max_time_ms [Integer, nil] optional server-side time limit in milliseconds passed to
5967
- # {Parse::MongoDB.aggregate} when mongo_direct is true. Pass +nil+ (the default) for no cap.
5968
- # @param raw_values [Boolean] when true, passes +rawValues: true+ to the Parse Server REST
6043
+ # {Parse::MongoDB.aggregate} when mongo_direct is true. Pass `nil` (the default) for no cap.
6044
+ # @param raw_values [Boolean] when true, passes `rawValues: true` to the Parse Server REST
5969
6045
  # aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
5970
- # @param raw_field_names [Boolean] when true, passes +rawFieldNames: true+ to the Parse Server
6046
+ # @param raw_field_names [Boolean] when true, passes `rawFieldNames: true` to the Parse Server
5971
6047
  # REST aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
5972
6048
  # @param allow_internal_fields [Boolean] when true, the mongo-direct path
5973
- # forwards +allow_internal_fields: true+ to {Parse::MongoDB.aggregate} so
5974
- # SDK-built ACL `$match` stages that legitimately reference +_rperm+ /
5975
- # +_wperm+ (emitted by {Parse::Query#readable_by}, +#publicly_readable+,
6049
+ # forwards `allow_internal_fields: true` to {Parse::MongoDB.aggregate} so
6050
+ # SDK-built ACL `$match` stages that legitimately reference `_rperm` /
6051
+ # `_wperm` (emitted by {Parse::Query#readable_by}, `#publicly_readable`,
5976
6052
  # and friends) pass the pipeline-security internal-fields denylist —
5977
- # matching the parity already held by +results_direct+ / +count_direct+ /
5978
- # +distinct_direct+. Set +true+ ONLY when this Aggregation's pipeline was
6053
+ # matching the parity already held by `results_direct` / `count_direct` /
6054
+ # `distinct_direct`. Set `true` ONLY when this Aggregation's pipeline was
5979
6055
  # built entirely from SDK constraint translation (no caller-supplied
5980
6056
  # stages); the credential-field guard (`_hashed_password`, session tokens,
5981
- # auth data) is what +allow_internal_fields+ relaxes, so it must never be
5982
- # set on a pipeline that interpolates user input. Defaults to +false+.
6057
+ # auth data) is what `allow_internal_fields` relaxes, so it must never be
6058
+ # set on a pipeline that interpolates user input. Defaults to `false`.
5983
6059
  def initialize(query, pipeline, verbose: nil, mongo_direct: false, max_time_ms: nil,
5984
6060
  raw_values: false, raw_field_names: false, allow_internal_fields: false)
5985
6061
  @query = query
@@ -6041,7 +6117,7 @@ module Parse
6041
6117
 
6042
6118
  # Execute aggregation directly on MongoDB
6043
6119
  # @param max_time_ms [Integer, nil] optional server-side time limit (milliseconds).
6044
- # Defaults to the value passed to {#initialize} via the +max_time_ms:+ keyword.
6120
+ # Defaults to the value passed to {#initialize} via the `max_time_ms:` keyword.
6045
6121
  # @return [Array<Hash>] raw MongoDB results
6046
6122
  def execute_direct!(max_time_ms: @max_time_ms)
6047
6123
  table = @query.instance_variable_get(:@table)
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "json"
5
5
  require "uri"
6
+ require "ipaddr"
6
7
  require_relative "../reranker"
7
8
 
8
9
  module Parse
@@ -197,10 +198,39 @@ module Parse
197
198
  unless uri.is_a?(URI::HTTPS) || uri.is_a?(URI::HTTP)
198
199
  raise ArgumentError, "Reranker::Cohere: base_url must be http(s) (got #{base_url.inspect})."
199
200
  end
201
+ # Never accept credentials embedded in the URL. They leak into
202
+ # logs / error messages / the Faraday connection, and userinfo
203
+ # can mask the real host (`https://evil@api.cohere.com/`).
204
+ unless uri.userinfo.nil?
205
+ raise ArgumentError,
206
+ "Reranker::Cohere: base_url must not embed userinfo (credentials in the URL)."
207
+ end
208
+ # Plaintext http:// would send the Cohere API key in the clear.
209
+ # Permit it ONLY for loopback hosts (a local dev proxy / sidecar);
210
+ # require https:// for anything that leaves the machine. NB:
211
+ # URI::HTTPS is a subclass of URI::HTTP, so match on the scheme
212
+ # string, not `is_a?`.
213
+ if uri.scheme == "http" && !loopback_host?(uri.host)
214
+ raise ArgumentError,
215
+ "Reranker::Cohere: base_url must be https:// for non-loopback hosts " \
216
+ "(refusing to send the API key over plaintext http to #{uri.host.inspect})."
217
+ end
200
218
  rescue URI::InvalidURIError => e
201
219
  raise ArgumentError, "Reranker::Cohere: invalid base_url #{base_url.inspect} (#{e.message})."
202
220
  end
203
221
 
222
+ # @return [Boolean] true for localhost / 127.0.0.0/8 / ::1 etc.
223
+ def loopback_host?(host)
224
+ return false if host.nil? || host.empty?
225
+ h = host.downcase.sub(/\A\[/, "").sub(/\]\z/, "") # strip IPv6 brackets
226
+ return true if h == "localhost"
227
+ begin
228
+ IPAddr.new(h).loopback?
229
+ rescue IPAddr::Error
230
+ false
231
+ end
232
+ end
233
+
204
234
  def safe_base_host
205
235
  URI.parse(@base_url).host
206
236
  rescue StandardError
data/lib/parse/schema.rb CHANGED
@@ -523,7 +523,7 @@ module Parse
523
523
  # via the conventional accessor names some Parse-Stack models
524
524
  # ship with), OR
525
525
  # - {Parse::Schema.default_class_level_permissions} if set, OR
526
- # - +nil+ to leave `classLevelPermissions` off the schema body so
526
+ # - `nil` to leave `classLevelPermissions` off the schema body so
527
527
  # Parse Server uses its built-in defaults.
528
528
  def model_class_level_permissions
529
529
  %i[class_level_permissions classLevelPermissions clp].each do |reader|
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.5.3"
9
+ VERSION = "5.5.5"
10
10
  end
11
11
  end
data/lib/parse/stack.rb CHANGED
@@ -44,14 +44,14 @@ module Parse
44
44
 
45
45
  # Fiber-local key consulted by the authentication middleware. A truthy
46
46
  # entry suppresses the master-key header for the duration of the block
47
- # set by {Parse.without_master_key}; a +:enabled+ entry forces the
47
+ # set by {Parse.without_master_key}; a `:enabled` entry forces the
48
48
  # master-key header back on inside a nested {Parse.with_master_key}
49
49
  # block.
50
50
  MASTER_KEY_STATE_KEY = :__parse_master_key_state__
51
51
 
52
- # Run +block+ with the master key suppressed for every Parse request
52
+ # Run `block` with the master key suppressed for every Parse request
53
53
  # originating in the current fiber. Equivalent to setting the
54
- # +X-Disable-Parse-Master-Key+ header on each request, but block-scoped
54
+ # `X-Disable-Parse-Master-Key` header on each request, but block-scoped
55
55
  # so callers can wrap a unit of work — e.g. running an action "as if
56
56
  # the configured master key were not available" — without threading
57
57
  # the header through every intermediate call.
@@ -105,7 +105,7 @@ module Parse
105
105
  # the previous value on exit.
106
106
  SESSION_TOKEN_STATE_KEY = :__parse_session_token__
107
107
 
108
- # Run +block+ with an ambient session token set for the current fiber.
108
+ # Run `block` with an ambient session token set for the current fiber.
109
109
  # Inside the block, every Parse request that doesn't explicitly pass
110
110
  # `session_token:` *and* doesn't explicitly request `use_master_key:
111
111
  # true` will be sent with this token. Equivalent to threading
@@ -140,8 +140,21 @@ module Parse
140
140
  def self.with_session(token)
141
141
  resolved = token.respond_to?(:session_token) ? token.session_token : token
142
142
  resolved = resolved.to_s if resolved
143
+ # Capture BEFORE any raise so the `ensure` always restores the real
144
+ # previous ambient (never clobbers an enclosing with_session).
143
145
  previous = Fiber[SESSION_TOKEN_STATE_KEY]
144
- Fiber[SESSION_TOKEN_STATE_KEY] = (resolved && !resolved.empty?) ? resolved : nil
146
+ # SEC-02: a present-but-blank (empty or whitespace) token is an unusable
147
+ # credential. The prior behavior stored a whitespace token as the ambient
148
+ # (only an exactly-empty string was treated as absent), and the request
149
+ # layer would then drop it and silently send the master key. Reject blank
150
+ # tokens loudly at the source instead. `nil` still means "no ambient".
151
+ if resolved.is_a?(String) && resolved.strip.empty?
152
+ raise ArgumentError,
153
+ "Parse.with_session was given a blank session token. A present-but-empty " \
154
+ "token is refused so the block cannot silently execute with master-key " \
155
+ "authority — pass a valid session token, or `nil` for no ambient session."
156
+ end
157
+ Fiber[SESSION_TOKEN_STATE_KEY] = resolved
145
158
  yield
146
159
  ensure
147
160
  Fiber[SESSION_TOKEN_STATE_KEY] = previous
@@ -584,11 +597,11 @@ module Parse
584
597
  # nil, falls back to {Parse.cache}.
585
598
  #
586
599
  # SECURITY: if you pass a raw Moneta-Redis store, build it with
587
- # +value_serializer: nil+. The lock release path reads the stored owner
588
- # token back (+store[key]+) to compare-and-delete; with Moneta's default
589
- # Marshal value serializer that read +Marshal.load+s bytes from Redis — an
600
+ # `value_serializer: nil`. The lock release path reads the stored owner
601
+ # token back (`store[key]`) to compare-and-delete; with Moneta's default
602
+ # Marshal value serializer that read `Marshal.load`s bytes from Redis — an
590
603
  # RCE vector on a shared/untrusted/MITM'd lock store. With
591
- # +value_serializer: nil+ the owner token is a plain string and is never
604
+ # `value_serializer: nil` the owner token is a plain string and is never
592
605
  # deserialized. Alternatively pass a {Parse::Cache::Redis} instance, which
593
606
  # uses a raw-string acquire/release path and avoids Marshal entirely.
594
607
  # @example
@@ -600,7 +613,7 @@ module Parse
600
613
  # Optional allowlist of {Parse::Object} subclasses that may use the
601
614
  # synchronize-create lock. When set, calls from any other class raise
602
615
  # {Parse::CreateLockUnavailableError}. When nil (default) with the global
603
- # default enabled, a one-time +[Parse::Stack:SECURITY]+ warning is emitted
616
+ # default enabled, a one-time `[Parse::Stack:SECURITY]` warning is emitted
604
617
  # noting the unbounded surface; the lock still applies to every class.
605
618
  #
606
619
  # **Inheritance behavior:** The allowlist check in
@@ -318,12 +318,12 @@ module Parse
318
318
  # CONFIRM the disable took effect from the SERVER's own view — a
319
319
  # positive post-condition rather than trusting the unlink response
320
320
  # alone. We must read the server directly here, NOT lean on the
321
- # in-memory #mfa_enabled? projection: Parse Server omits +authData+
321
+ # in-memory #mfa_enabled? projection: Parse Server omits `authData`
322
322
  # entirely for a user with no providers, so once MFA is unlinked an
323
- # ordinary fetch carries no +authData+ key at all and therefore can
324
- # never clear the +{ mfa: { status: "enabled" } }+ value pinned at
323
+ # ordinary fetch carries no `authData` key at all and therefore can
324
+ # never clear the `{ mfa: { status: "enabled" } }` value pinned at
325
325
  # enrollment. An enabled account's own (session-token) read returns
326
- # +authData.mfa+; a disabled one omits it — so an absent/mfa-less
326
+ # `authData.mfa`; a disabled one omits it — so an absent/mfa-less
327
327
  # authData on this trusted self-read is the authoritative signal.
328
328
  if mfa_enabled_on_server?
329
329
  raise MFA::VerificationError, "MFA disable did not take effect (still enabled after unlink)"
@@ -337,27 +337,27 @@ module Parse
337
337
  # verification entirely, so the caller must prove (out-of-band) that
338
338
  # the operator initiating the disable is authorized to do so.
339
339
  #
340
- # The +authorized_by:+ keyword is required and must be a
340
+ # The `authorized_by:` keyword is required and must be a
341
341
  # {Parse::User} (or {Parse::Pointer} to a User) representing the
342
342
  # operator performing the override. The caller is responsible for
343
343
  # verifying that operator's privileges (e.g. via a role check). An
344
- # optional +admin_role:+ argument lets this method enforce a role
344
+ # optional `admin_role:` argument lets this method enforce a role
345
345
  # membership check on the operator using the existing role-hierarchy
346
346
  # support; when given, the operator must belong to the role (or any
347
- # of its child roles) or +ForbiddenError+ is raised.
347
+ # of its child roles) or `ForbiddenError` is raised.
348
348
  #
349
349
  # @param authorized_by [Parse::User, Parse::Pointer] the operator
350
350
  # performing the override. Required.
351
351
  # @param admin_role [Parse::Role, String, nil] role (or role name)
352
- # that +authorized_by+ must belong to. Library-enforced. Either
353
- # this or +allow_unverified: true+ is REQUIRED (fail-closed).
352
+ # that `authorized_by` must belong to. Library-enforced. Either
353
+ # this or `allow_unverified: true` is REQUIRED (fail-closed).
354
354
  # @param allow_unverified [Boolean] explicitly accept caller-side
355
- # authorization without a library role check. Defaults to +false+;
356
- # must be set deliberately to bypass MFA without an +admin_role+.
355
+ # authorization without a library role check. Defaults to `false`;
356
+ # must be set deliberately to bypass MFA without an `admin_role`.
357
357
  # @return [Boolean] True if disabled successfully.
358
- # @raise [ArgumentError] when +authorized_by:+ is missing or not a User.
359
- # @raise [Parse::MFA::ForbiddenError] when neither +admin_role+ nor
360
- # +allow_unverified:+ is supplied, or when +admin_role+ is supplied
358
+ # @raise [ArgumentError] when `authorized_by:` is missing or not a User.
359
+ # @raise [Parse::MFA::ForbiddenError] when neither `admin_role` nor
360
+ # `allow_unverified:` is supplied, or when `admin_role` is supplied
361
361
  # and the operator is not a member.
362
362
  #
363
363
  # @example Library-enforced role check (preferred)
@@ -415,8 +415,8 @@ module Parse
415
415
  end
416
416
 
417
417
  # Refresh auth_data, then drop the in-memory MFA projection. As in
418
- # #disable_mfa!, a disabled user's read omits +authData+, so the
419
- # +{ mfa: { status: "enabled" } }+ value pinned at enrollment won't
418
+ # #disable_mfa!, a disabled user's read omits `authData`, so the
419
+ # `{ mfa: { status: "enabled" } }` value pinned at enrollment won't
420
420
  # self-clear on fetch — clear it explicitly so #mfa_enabled? reports
421
421
  # the truth after a master-key disable.
422
422
  fetch
@@ -426,7 +426,7 @@ module Parse
426
426
  end
427
427
 
428
428
  # @deprecated Use {#disable_mfa_master_key!} with an explicit
429
- # +authorized_by:+ argument. The old name had no authorization gate
429
+ # `authorized_by:` argument. The old name had no authorization gate
430
430
  # and acted as a one-call IDOR primitive when invoked on an
431
431
  # attacker-controlled user instance.
432
432
  def disable_mfa_admin!(*args, **kwargs)
@@ -496,10 +496,10 @@ module Parse
496
496
 
497
497
  # @!visibility private
498
498
  # Authoritative server-side MFA check via a trusted self-read.
499
- # Reads +authData.mfa+ straight from a fresh session-token fetch
499
+ # Reads `authData.mfa` straight from a fresh session-token fetch
500
500
  # rather than the (possibly stale) in-memory projection. An enabled
501
- # account returns +authData.mfa+ with a +status+/+secret+; a disabled
502
- # one omits +authData+ — so absence (or an mfa-less authData) means
501
+ # account returns `authData.mfa` with a `status`/`secret`; a disabled
502
+ # one omits `authData` — so absence (or an mfa-less authData) means
503
503
  # disabled.
504
504
  # @return [Boolean]
505
505
  def mfa_enabled_on_server?
@@ -512,13 +512,13 @@ module Parse
512
512
 
513
513
  # @!visibility private
514
514
  # Drop the in-memory MFA projection after a disable. A disabled user's
515
- # server read omits +authData+ entirely, so an ordinary fetch can
516
- # never clear the +{ mfa: { status: "enabled" } }+ value pinned at
517
- # enrollment; do it explicitly here. Only the +mfa+ subkey is removed
515
+ # server read omits `authData` entirely, so an ordinary fetch can
516
+ # never clear the `{ mfa: { status: "enabled" } }` value pinned at
517
+ # enrollment; do it explicitly here. Only the `mfa` subkey is removed
518
518
  # (any anonymous/OAuth authData is preserved), and the assignment runs
519
- # through the non-dirtying hydration path inside a +with_authdata_trust+
519
+ # through the non-dirtying hydration path inside a `with_authdata_trust`
520
520
  # scope so it is neither stripped nor marked dirty — a later #save will
521
- # not resend +authData+.
521
+ # not resend `authData`.
522
522
  def clear_local_mfa_projection!
523
523
  cleared = auth_data.is_a?(Hash) ? auth_data.dup : {}
524
524
  cleared.delete("mfa")