parse-stack-next 5.5.1 → 5.5.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fed615f71ab3b45bd9f10e2947c50ebbcba075b15b0a8786f0a269d4e59ebd6
4
- data.tar.gz: e9528b3f4bc811cef21494089f6e5eb5aaed43732ddf926a64ccc2a050d8742d
3
+ metadata.gz: 2d234769063a852058f1024815a4cb5e804646bfe54c5bd316e4730e4e451451
4
+ data.tar.gz: cf3d7dcdeee49dd7b74fdaf241d205c98519e0464171c232bc565a2d844fe71a
5
5
  SHA512:
6
- metadata.gz: 0d1d0ee29e3787585f246e8b006f81aa514bc85732fe32c1c175f5734d60b8fadbf5f2d127f7d7fa6df38e49303e334ad2c31a10d85cb3b7e7f8eba1a1bf836d
7
- data.tar.gz: 8c032babfcc42f16327a874d4cbf358ed7aade09157da7e29dd879578454b23cff7e7cbc3e860fec7891c8e43c12b1362778039550bf3371c63786d122fb9ae7
6
+ metadata.gz: e32cb99c46fc779dcb7595b7fe8dd95592411856ee079bf969f8c9f2a28cd7739a3785af6cad45fe741b868d69f8c1ce74c7d46bd8cd5a439ea8d7c225434ee4
7
+ data.tar.gz: 51548942c4b24a7e9c3d5323269962ba9212dce7f3b58ab6bddc3d9199df9a8976698b3bfce690c5dbd42726ea1260825dfd8567f777b14bb1793d941cdb302e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.5.2
4
+
5
+ #### Large aggregation pipelines no longer fail with "Invalid aggregate stage '0'"
6
+
7
+ - **FIXED**: An aggregation whose request URL exceeds ~2KB (for example a
8
+ `group_by`, `group_by_date`, `distinct`, or custom `aggregate` pipeline with
9
+ a large `$in` / `$match`) is rewritten from a GET to a POST carrying
10
+ `_method=GET`, with the query moved into the request body. The pipeline was
11
+ sent in the body as a URL-encoded string, but Parse Server's aggregate
12
+ endpoint only JSON-decodes query-string params, not body params — so the
13
+ pipeline arrived as a raw string and was rejected with
14
+ `Invalid aggregate stage '0'`, causing the aggregation to return an empty
15
+ result. The long-URL override now sends a JSON body for the aggregate
16
+ endpoint so the pipeline is delivered as a real array (boolean params such as
17
+ `rawValues` are preserved as booleans). The historical URL-encoded override is
18
+ unchanged for `find` and other endpoints, which Parse Server already decodes
19
+ correctly.
20
+
21
+ #### Aggregations inside `Parse.with_session` blocks are now scoped
22
+
23
+ - **FIXED**: `group_by_date`, `group_by`, `distinct`, and `count` (aggregation
24
+ branch) now detect the ambient session token set by `Parse.with_session` and
25
+ treat the query as scoped — consistent with how `Parse::Client#request`
26
+ already scopes REST find/get/count calls in the same block. Previously the
27
+ `query_is_scoped?` / `distinct_query_is_scoped?` checks consulted only the
28
+ query instance's own `session_token=` / `scope_to_user` / `scope_to_role`
29
+ and ignored `Parse.current_session_token`, so an aggregation inside a
30
+ `with_session` block ran unscoped as the master key and returned all rows
31
+ regardless of ACL. The checks now include the ambient: when scoped and
32
+ mongo-direct is available the aggregation auto-promotes (ACL/CLP enforced);
33
+ when scoped and mongo-direct is unavailable it fails closed with
34
+ `MongoDirectRequired` rather than silently leaking rows.
35
+ - **FIXED**: `group_by_date` now also fails closed (`MongoDirectRequired`) when
36
+ the query is scoped but mongo-direct is unavailable — matching the existing
37
+ behavior of `group_by`, `distinct`, and `count`. Previously `group_by_date`
38
+ silently fell back to the REST `/aggregate` endpoint in that case.
39
+ - **FIXED**: A regression introduced in 5.5.1 where `group_by_date`,
40
+ `group_by`, and pipeline-based aggregations called inside a
41
+ `Parse.with_session` block returned empty results `{}`. The ambient session
42
+ token was forwarded as an HTTP session-token header (suppressing the master
43
+ key), causing Parse Server's REST `/aggregate` endpoint — which is
44
+ master-key-only — to return a 401/403. The REST aggregate call sites now
45
+ force `use_master_key: true` so the ambient cannot suppress it, unless the
46
+ caller explicitly set `use_master_key: false`.
47
+
3
48
  ### 5.5.1
4
49
 
5
50
  #### Mongo-direct reads inside `Parse.with_session` are now scoped, not master
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- parse-stack-next (5.5.1)
4
+ parse-stack-next (5.5.2)
5
5
  activemodel (>= 6.1, < 9)
6
6
  activesupport (>= 6.1, < 9)
7
7
  connection_pool (>= 2.2, < 4)
@@ -285,14 +285,32 @@ module Parse
285
285
  # to be POST instead of GET and send the query parameters in the body of the POST request.
286
286
  # The standard maximum POST request (which is a server setting), is usually set to 20MBs
287
287
  if env[:method] == :get && env[:url].to_s.length >= MAX_URL_LENGTH
288
- env[:request_headers][HTTP_METHOD_OVERRIDE] = "GET"
289
- env[:request_headers][CONTENT_TYPE] = "application/x-www-form-urlencoded"
290
- # parse-sever looks for method overrides in the body under the `_method` param.
291
- # so we will add it to the query string, which will now go into the body.
292
- env[:body] = "_method=GET&" + env[:url].query
293
- env[:url].query = nil
294
- #override
295
- env[:method] = :post
288
+ if aggregate_request?(env[:url])
289
+ # Parse Server's AggregateRouter only JSON-decodes query-string
290
+ # params (via JSONFromQuery); it does NOT decode a `pipeline` param
291
+ # that arrives in the request body. The urlencoded override below
292
+ # would therefore deliver `pipeline` as a raw JSON *string*, which
293
+ # AggregateRouter.getPipeline mis-reads character-by-character and
294
+ # rejects with "Invalid aggregate stage '0'". Send a JSON body
295
+ # instead so the pipeline survives as a real Array. `_method=GET`
296
+ # still routes Parse Server to its GET-only aggregate handler.
297
+ env[:request_headers][HTTP_METHOD_OVERRIDE] = "GET"
298
+ env[:request_headers][CONTENT_TYPE] = CONTENT_TYPE_FORMAT
299
+ env[:body] = aggregate_override_body(env[:url].query)
300
+ env[:url].query = nil
301
+ env[:method] = :post
302
+ else
303
+ env[:request_headers][HTTP_METHOD_OVERRIDE] = "GET"
304
+ env[:request_headers][CONTENT_TYPE] = "application/x-www-form-urlencoded"
305
+ # parse-server looks for method overrides in the body under the `_method` param.
306
+ # so we will add it to the query string, which will now go into the body.
307
+ # `.to_s` guards the (contrived but possible) case of a >=2KB URL whose
308
+ # length is all path and no query — nil + String would raise TypeError.
309
+ env[:body] = "_method=GET&" + env[:url].query.to_s
310
+ env[:url].query = nil
311
+ #override
312
+ env[:method] = :post
313
+ end
296
314
  # else if not a get, always make sure the request is JSON encoded if the content type matches
297
315
  elsif env[:request_headers][CONTENT_TYPE] == CONTENT_TYPE_FORMAT &&
298
316
  (env[:body].is_a?(Hash) || env[:body].is_a?(Array))
@@ -334,6 +352,51 @@ module Parse
334
352
  response_env[:body] = r
335
353
  end
336
354
  end
355
+
356
+ private
357
+
358
+ # Whether the request targets Parse Server's `/aggregate/<Class>`
359
+ # endpoint. Used by {#call!} to pick the JSON-body form of the
360
+ # long-URL GET→POST override (the aggregate endpoint does not
361
+ # JSON-decode a body `pipeline` param, unlike `where`).
362
+ #
363
+ # Anchored to the final two path segments: `.../aggregate/<ClassName>`
364
+ # where <ClassName> is the last segment (no further slashes). The
365
+ # className is mandatory and slash-free — see
366
+ # {Parse::API::Aggregate#aggregate_uri_path}, which validates it via
367
+ # PathSegment.identifier! — so a real aggregate URL always ends this way.
368
+ # A `find` request is `.../classes/<ClassName>` (no match), a class
369
+ # merely *named* with "aggregate" (e.g. `MyAggregateData`) does not match,
370
+ # and an `/aggregate/` segment appearing earlier in a custom mount prefix
371
+ # (e.g. `/aggregate/api/classes/Foo`) does not match either.
372
+ # @param url [URI] the request URL.
373
+ # @return [Boolean]
374
+ def aggregate_request?(url)
375
+ url.path.to_s.match?(%r{/aggregate/[^/]+/?\z})
376
+ end
377
+
378
+ # Build the JSON request body for a long-URL aggregate GET→POST
379
+ # override. Reconstructs the params from the encoded query string and
380
+ # JSON-decodes each value so the `pipeline` Array (and boolean
381
+ # `rawValues`/`rawFieldNames`) reach Parse Server as real types rather
382
+ # than strings. A value that is not itself JSON is passed through
383
+ # unchanged. `_method=GET` is injected so Parse Server routes the POST
384
+ # to its GET-only aggregate handler.
385
+ # @param query_string [String, nil] the encoded query string.
386
+ # @return [String] the JSON body to send.
387
+ def aggregate_override_body(query_string)
388
+ params = Faraday::Utils.parse_query(query_string.to_s) || {}
389
+ body = { "_method" => "GET" }
390
+ params.each do |key, value|
391
+ body[key] =
392
+ begin
393
+ JSON.parse(value)
394
+ rescue JSON::ParserError, TypeError
395
+ value
396
+ end
397
+ end
398
+ body.to_json
399
+ end
337
400
  end
338
401
  end #Middleware
339
402
  end
data/lib/parse/query.rb CHANGED
@@ -1811,13 +1811,25 @@ module Parse
1811
1811
  # Whether this query carries a non-master-key auth scope. Used by
1812
1812
  # `#distinct` (and group_by aggregations) to decide whether to
1813
1813
  # auto-promote the REST aggregate path to mongo-direct so the SDK's
1814
- # ACLScope / CLPScope enforcement actually runs.
1814
+ # ACLScope / CLPScope enforcement actually runs. Also detects the
1815
+ # fiber-local ambient session set by Parse.with_session so that
1816
+ # aggregations inside a with_session block are treated as scoped —
1817
+ # consistent with how Parse::Client#request already scopes REST calls.
1815
1818
  # @return [Boolean]
1816
1819
  # @api private
1817
1820
  def distinct_query_is_scoped?
1818
1821
  return true if @session_token.is_a?(String) && !@session_token.empty?
1819
1822
  return true if @acl_user
1820
1823
  return true if @acl_role
1824
+ # An ambient Parse.with_session counts as scope ONLY when the query did
1825
+ # not explicitly request master-key mode — mirroring Parse::Client#request,
1826
+ # where an explicit use_master_key: true is a deliberate admin call that
1827
+ # skips the ambient session. Otherwise an admin aggregation inside a
1828
+ # with_session block would be wrongly forced to mongo-direct / fail-closed.
1829
+ unless use_master_key == true
1830
+ ambient = ambient_session_token
1831
+ return true if ambient.is_a?(String) && !ambient.empty?
1832
+ end
1821
1833
  false
1822
1834
  end
1823
1835
 
@@ -1832,12 +1844,13 @@ module Parse
1832
1844
  def raise_scoped_aggregation_requires_mongo_direct!
1833
1845
  raise MongoDirectRequired,
1834
1846
  "[Parse::Query] This scoped aggregation (session_token / " \
1835
- "scope_to_user / scope_to_role) requires mongo-direct so the " \
1836
- "SDK can enforce ACL/CLP. Parse Server's REST /aggregate " \
1837
- "endpoint is master-key-only and enforces neither, so routing " \
1838
- "it there would silently run unscoped as the master key. " \
1839
- "Enable mongo-direct via Parse::MongoDB.configure(...), or " \
1840
- "rewrite without the aggregation terminal."
1847
+ "scope_to_user / scope_to_role, or an active Parse.with_session " \
1848
+ "block) requires mongo-direct so the SDK can enforce ACL/CLP. " \
1849
+ "Parse Server's REST /aggregate endpoint is master-key-only and " \
1850
+ "enforces neither, so routing it there would silently run unscoped " \
1851
+ "as the master key. Enable mongo-direct via " \
1852
+ "Parse::MongoDB.configure(...), or rewrite without the " \
1853
+ "aggregation terminal."
1841
1854
  end
1842
1855
 
1843
1856
  # Scope a query to a specific user's row-level ACL when it auto-routes
@@ -5996,13 +6009,21 @@ module Parse
5996
6009
  if @mongo_direct && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
5997
6010
  @cached_response = execute_direct!
5998
6011
  else
6012
+ # REST /aggregate is master-key-only. An ambient Parse.with_session
6013
+ # block would suppress the master key via session_token, causing a
6014
+ # 401/403. Force use_master_key unless the caller explicitly disabled
6015
+ # it (use_master_key: false is a deliberate client-mode decision).
6016
+ # `.dup` keeps the master-key flip local to this call even if `_opts`
6017
+ # ever returns a shared/memoized hash.
6018
+ rest_opts = @query.send(:_opts).dup
6019
+ rest_opts[:use_master_key] = true unless rest_opts[:use_master_key] == false
5999
6020
  @cached_response = @query.client.aggregate_pipeline(
6000
6021
  @query.instance_variable_get(:@table),
6001
6022
  @pipeline,
6002
6023
  headers: {},
6003
6024
  raw_values: @raw_values,
6004
6025
  raw_field_names: @raw_field_names,
6005
- **@query.send(:_opts),
6026
+ **rest_opts,
6006
6027
  )
6007
6028
  end
6008
6029
 
@@ -6388,16 +6409,24 @@ module Parse
6388
6409
  # @return [Array<Hash>] raw aggregation results
6389
6410
  def raw(operation, aggregation_expr)
6390
6411
  formatted_group_field = @query.send(:format_aggregation_field, @group_field)
6391
- pipeline = build_pipeline(formatted_group_field, aggregation_expr)
6392
6412
 
6393
- response = @query.client.aggregate_pipeline(
6394
- @query.instance_variable_get(:@table),
6395
- pipeline,
6396
- headers: {},
6397
- **@query.send(:_opts),
6398
- )
6413
+ # Build the same pipeline the count/sum/etc. terminals use, then delegate
6414
+ # to Query#aggregate. That central path handles scoped-query routing
6415
+ # (session_token / acl_user / acl_role / ambient Parse.with_session →
6416
+ # auto-promote to mongo-direct, or fail closed when unavailable) so a
6417
+ # scoped `raw` is never sent to the master-key-only REST /aggregate
6418
+ # endpoint, and it returns the raw Array<Hash> rows this method documents.
6419
+ # `$match` from the query's where constraints is added by Query#aggregate.
6420
+ pipeline = []
6421
+ pipeline << { "$unwind" => "$#{formatted_group_field}" } if @flatten_arrays
6422
+ pipeline << { "$group" => { "_id" => "$#{formatted_group_field}", "count" => aggregation_expr } }
6423
+ add_fields = size_addfields_stage
6424
+ pipeline << add_fields if add_fields
6425
+ sort = sort_stage
6426
+ pipeline << sort if sort
6427
+ pipeline << { "$project" => { "_id" => 0, "objectId" => "$_id", "count" => 1 } }
6399
6428
 
6400
- response.result || []
6429
+ @query.aggregate(pipeline, verbose: @query.instance_variable_get(:@verbose_aggregate)).raw || []
6401
6430
  end
6402
6431
 
6403
6432
  # Count the number of items in each group.
@@ -6541,8 +6570,12 @@ module Parse
6541
6570
  # already does this auto-promotion (lib/parse/agent/tools.rb), this
6542
6571
  # is the equivalent at the Query layer for direct SDK callers.
6543
6572
  use_mongo_direct = @mongo_direct
6544
- if !use_mongo_direct && query_is_scoped? && parse_mongodb_available?
6545
- use_mongo_direct = true
6573
+ if !use_mongo_direct && query_is_scoped?
6574
+ if parse_mongodb_available?
6575
+ use_mongo_direct = true
6576
+ else
6577
+ @query.send(:raise_scoped_aggregation_requires_mongo_direct!)
6578
+ end
6546
6579
  end
6547
6580
 
6548
6581
  if use_mongo_direct
@@ -6779,15 +6812,22 @@ module Parse
6779
6812
  end
6780
6813
 
6781
6814
  # Whether the parent query carries any non-master-key auth scope. A
6782
- # session_token, acl_user, or acl_role means the caller expects the
6783
- # results to be filtered by ACL — which only happens on the SDK's
6784
- # mongo-direct path. Used to decide whether to auto-promote the REST
6785
- # aggregation path to mongo-direct.
6815
+ # session_token, acl_user, acl_role, or an active Parse.with_session
6816
+ # ambient means the caller expects ACL-filtered results — which only
6817
+ # the SDK's mongo-direct path provides. Used to decide whether to
6818
+ # auto-promote the REST aggregation path to mongo-direct.
6786
6819
  def query_is_scoped?
6787
6820
  st = @query.session_token
6788
6821
  return true if st.is_a?(String) && !st.empty?
6789
6822
  return true if @query.instance_variable_get(:@acl_user)
6790
6823
  return true if @query.instance_variable_get(:@acl_role)
6824
+ # Ambient Parse.with_session counts as scope only when the query did not
6825
+ # explicitly set use_master_key: true (matches Parse::Client#request
6826
+ # precedence — an explicit master-key call skips the ambient session).
6827
+ unless @query.use_master_key == true
6828
+ ambient = @query.send(:ambient_session_token)
6829
+ return true if ambient.is_a?(String) && !ambient.empty?
6830
+ end
6791
6831
  false
6792
6832
  end
6793
6833
 
@@ -7228,14 +7268,19 @@ module Parse
7228
7268
  # Format the date field name
7229
7269
  formatted_date_field = @query.send(:format_aggregation_field, @date_field)
7230
7270
 
7231
- # Auto-promote scoped queries to mongo-direct. See the matching
7232
- # block in `GroupBy#execute_group_aggregation` for the full
7233
- # rationale REST `/aggregate` is master-key-only and unscoped, so
7234
- # session_token / acl_user / acl_role queries need the SDK's
7235
- # mongo-direct enforcement layers to actually filter results.
7271
+ # Auto-promote scoped queries to mongo-direct. REST `/aggregate` is
7272
+ # master-key-only and enforces neither ACL nor CLP — a scoped query
7273
+ # (session_token / acl_user / acl_role, or an active
7274
+ # Parse.with_session block) must use the SDK's enforcement layers.
7275
+ # Fail closed if mongo-direct is unavailable rather than silently
7276
+ # returning unscoped rows. Mirrors the scoped-query gate in Query#aggregate.
7236
7277
  use_mongo_direct = @mongo_direct
7237
- if !use_mongo_direct && query_is_scoped? && parse_mongodb_available?
7238
- use_mongo_direct = true
7278
+ if !use_mongo_direct && query_is_scoped?
7279
+ if parse_mongodb_available?
7280
+ use_mongo_direct = true
7281
+ else
7282
+ @query.send(:raise_scoped_aggregation_requires_mongo_direct!)
7283
+ end
7239
7284
  end
7240
7285
 
7241
7286
  if use_mongo_direct
@@ -7281,11 +7326,21 @@ module Parse
7281
7326
  puts "[VERBOSE AGGREGATE] Sending to: #{@query.instance_variable_get(:@table)}"
7282
7327
  end
7283
7328
 
7329
+ # Parse Server's REST /aggregate endpoint is master-key-only. An active
7330
+ # Parse.with_session block sets a fiber-local ambient session token that
7331
+ # Parse::Client#request picks up and uses in place of the master key,
7332
+ # causing a 401/403 on this endpoint. Force use_master_key: true so the
7333
+ # ambient session cannot suppress it — unless the caller explicitly set
7334
+ # use_master_key: false (deliberate client-mode / session-token intent).
7335
+ # `.dup` keeps the master-key flip local to this call (see Aggregation#execute!).
7336
+ rest_opts = @query.send(:_opts).dup
7337
+ rest_opts[:use_master_key] = true unless rest_opts[:use_master_key] == false
7338
+
7284
7339
  response = @query.client.aggregate_pipeline(
7285
7340
  @query.instance_variable_get(:@table),
7286
7341
  pipeline,
7287
7342
  headers: {},
7288
- **@query.send(:_opts),
7343
+ **rest_opts,
7289
7344
  )
7290
7345
 
7291
7346
  if @query.instance_variable_get(:@verbose_aggregate)
@@ -7312,6 +7367,18 @@ module Parse
7312
7367
  end
7313
7368
  result_hash
7314
7369
  else
7370
+ unless response.success?
7371
+ # Surface the failure (the result would otherwise be a silent `{}`)
7372
+ # through the configured logger rather than unconditional stderr.
7373
+ # Log the error code + message, not a full `inspect`, to avoid
7374
+ # echoing an unbounded server payload into logs.
7375
+ logger = Parse.respond_to?(:logger) ? Parse.logger : nil
7376
+ logger&.warn(
7377
+ "[Parse::GroupByDate] aggregate failed " \
7378
+ "(#{@query.instance_variable_get(:@table)} :#{@date_field} :#{@interval}): " \
7379
+ "code=#{response.code} #{response.error}"
7380
+ )
7381
+ end
7315
7382
  {}
7316
7383
  end
7317
7384
  end
@@ -7492,15 +7559,23 @@ module Parse
7492
7559
  { "$sort" => { field => dir } }
7493
7560
  end
7494
7561
 
7495
- # Mirror of {GroupBy#query_is_scoped?}. A session_token, acl_user, or
7496
- # acl_role on the parent query means the caller expects ACL filtering,
7497
- # which only the mongo-direct path provides — Parse Server REST
7498
- # `/aggregate` is master-key-only and unscoped.
7562
+ # Mirror of {GroupBy#query_is_scoped?}. A session_token, acl_user,
7563
+ # acl_role, or an active Parse.with_session ambient means the caller
7564
+ # expects ACL-filtered results — which only the mongo-direct path
7565
+ # provides. Parse Server REST `/aggregate` is master-key-only and
7566
+ # unscoped.
7499
7567
  def query_is_scoped?
7500
7568
  st = @query.session_token
7501
7569
  return true if st.is_a?(String) && !st.empty?
7502
7570
  return true if @query.instance_variable_get(:@acl_user)
7503
7571
  return true if @query.instance_variable_get(:@acl_role)
7572
+ # Ambient Parse.with_session counts as scope only when the query did not
7573
+ # explicitly set use_master_key: true (matches Parse::Client#request
7574
+ # precedence — an explicit master-key call skips the ambient session).
7575
+ unless @query.use_master_key == true
7576
+ ambient = @query.send(:ambient_session_token)
7577
+ return true if ambient.is_a?(String) && !ambient.empty?
7578
+ end
7504
7579
  false
7505
7580
  end
7506
7581
 
@@ -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.1"
9
+ VERSION = "5.5.2"
10
10
  end
11
11
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.5.1
4
+ version: 5.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin