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
@@ -85,32 +85,32 @@ module Parse
85
85
  end
86
86
 
87
87
  # This is the core class for all app specific Parse table subclasses. This class
88
- # in herits from Parse::Pointer since an Object is a Parse::Pointer with additional fields,
88
+ # inherits from Parse::Pointer since an Object is a Parse::Pointer with additional fields,
89
89
  # at a minimum, created_at, updated_at and ACLs. This class also handles all
90
90
  # the relational types of associations in a Parse application and handles the main CRUD operations.
91
91
  #
92
92
  # As the parent class to all custom subclasses, this class provides the default property schema:
93
93
  #
94
- # class Parse::Object
95
- # # All subclasses will inherit these properties by default.
94
+ # class Parse::Object
95
+ # # All subclasses will inherit these properties by default.
96
96
  #
97
- # # the objectId column of a record.
98
- # property :id, :string, field: :objectId
97
+ # # the objectId column of a record.
98
+ # property :id, :string, field: :objectId
99
99
  #
100
- # # The the last updated date for a record (Parse::Date)
101
- # property :updated_at, :date
100
+ # # The last updated date for a record (Parse::Date)
101
+ # property :updated_at, :date
102
102
  #
103
- # # The original creation date of a record (Parse::Date)
104
- # property :created_at, :date
103
+ # # The original creation date of a record (Parse::Date)
104
+ # property :created_at, :date
105
105
  #
106
- # # The Parse::ACL field
107
- # property :acl, :acl, field: :ACL
106
+ # # The Parse::ACL field
107
+ # property :acl, :acl, field: :ACL
108
108
  #
109
- # end
109
+ # end
110
110
  #
111
111
  # Most Pointers and Object subclasses are treated the same. Therefore, defining a class Artist < Parse::Object
112
112
  # that only has `id` set, will be treated as a pointer. Therefore a Parse::Object can be in a "pointer" state
113
- # based on the data that it contains. Becasue of this, it is possible to take a Artist instance
113
+ # based on the data that it contains. Because of this, it is possible to take an Artist instance
114
114
  # (in this example), that is in a pointer state, and fetch the rest of the data for that particular
115
115
  # record without having to create a new object. Doing so would now mark it as not being a pointer anymore.
116
116
  # This is important to the understanding on how relations and properties are handled.
@@ -125,7 +125,7 @@ module Parse
125
125
  #
126
126
  # Associations:
127
127
  #
128
- # Parse supports a three main types of relational associations. One type of
128
+ # Parse supports three main types of relational associations. One type of
129
129
  # relation is the `One-to-One` association. This is implemented through a
130
130
  # specific column in Parse with a Pointer data type. This pointer column,
131
131
  # contains a local value that refers to a different record in a separate Parse
@@ -1329,25 +1329,25 @@ module Parse
1329
1329
  #
1330
1330
  # @param hash [Hash] the hash representing the object.
1331
1331
  # Untrusted by default: keys in
1332
- # {Parse::Properties::PROTECTED_INITIALIZE_KEYS} (+sessionToken+,
1333
- # +_rperm+, +_wperm+, +_hashed_password+, +authData+, +roles+)
1334
- # are filtered out even when an +objectId+ is present. This
1335
- # closes the mass-assignment hole where +klass.new(attacker_params)+
1336
- # on a hash that happens to include +objectId+ would overwrite
1332
+ # {Parse::Properties::PROTECTED_INITIALIZE_KEYS} (`sessionToken`,
1333
+ # `_rperm`, `_wperm`, `_hashed_password`, `authData`, `roles`)
1334
+ # are filtered out even when an `objectId` is present. This
1335
+ # closes the mass-assignment hole where `klass.new(attacker_params)`
1336
+ # on a hash that happens to include `objectId` would overwrite
1337
1337
  # session tokens, ACLs, and auth data. Use {Parse::Object.build}
1338
1338
  # for trusted hydration from server JSON; it bypasses the filter.
1339
1339
  # @return [Parse::Object] a the corresponding Parse::Object or subclass.
1340
1340
  def initialize(opts = {})
1341
- # Trusted hydration is signalled by the +@_trusted_init+ instance
1342
- # variable rather than by a +trusted:+ keyword argument. Using a
1343
- # keyword would break subclasses that override +initialize(*args)+
1344
- # and call +super+ — Ruby 3 keyword-arg semantics would convert the
1345
- # kwarg into a positional Hash through the variadic +*args+ splat
1346
- # and the subsequent +super+ would arrive at this method with two
1341
+ # Trusted hydration is signalled by the `@_trusted_init` instance
1342
+ # variable rather than by a `trusted:` keyword argument. Using a
1343
+ # keyword would break subclasses that override `initialize(*args)`
1344
+ # and call `super` — Ruby 3 keyword-arg semantics would convert the
1345
+ # kwarg into a positional Hash through the variadic `*args` splat
1346
+ # and the subsequent `super` would arrive at this method with two
1347
1347
  # positional args. The internal hydration paths
1348
1348
  # ({Parse::Object.build}, {Parse::Pointer} autofetch,
1349
- # {Parse::User#session}) +allocate+ the object, set the ivar, then
1350
- # invoke +initialize+ so subclass overrides still fire and pick up
1349
+ # {Parse::User#session}) `allocate` the object, set the ivar, then
1350
+ # invoke `initialize` so subclass overrides still fire and pick up
1351
1351
  # the trust signal here.
1352
1352
  trusted = @_trusted_init == true
1353
1353
  @_trusted_init = nil
@@ -1369,7 +1369,7 @@ module Parse
1369
1369
  # sessionToken / _rperm / _wperm / _hashed_password / authData /
1370
1370
  # roles. The narrow list deliberately allows createdAt /
1371
1371
  # updatedAt / className / __type through so the legitimate
1372
- # +Klass.new("objectId" => id, "createdAt" => ts, …)+
1372
+ # `Klass.new("objectId" => id, "createdAt" => ts, …)`
1373
1373
  # cache-rehydrate pattern keeps working.
1374
1374
  apply_attributes!(opts,
1375
1375
  dirty_track: !dirty_track,
@@ -1772,13 +1772,13 @@ module Parse
1772
1772
  # @return [Parse::Object] an instance of the Parse subclass
1773
1773
  def self.build(json, table = nil, fetched_keys: nil, nested_fetched_keys: nil)
1774
1774
  # Precedence (most → least authoritative):
1775
- # 1. Caller-supplied +table+ — caller knows the expected class
1775
+ # 1. Caller-supplied `table` — caller knows the expected class
1776
1776
  # (e.g. webhook payload routed to a typed handler, has_many that
1777
1777
  # knows its declared target class).
1778
- # 2. The subclass +parse_class+ when invoked on a Parse::Object
1778
+ # 2. The subclass `parse_class` when invoked on a Parse::Object
1779
1779
  # subclass directly (Song.build(json)).
1780
1780
  # 3. The className inside the JSON — only trusted when neither of
1781
- # the above is available (e.g. base-class +Parse::Object.build+
1781
+ # the above is available (e.g. base-class `Parse::Object.build`
1782
1782
  # on untyped JSON).
1783
1783
  # Warn on mismatch between an explicit caller class and the
1784
1784
  # payload-supplied className so type-confusion attacks surface in
@@ -1823,9 +1823,9 @@ module Parse
1823
1823
  # Trusted hydration: this path runs on server-side JSON (response
1824
1824
  # bodies, webhook payloads that have already been scrubbed,
1825
1825
  # autofetch results). Server responses legitimately include
1826
- # protected keys like +sessionToken+, +_rperm+ that must populate
1827
- # the in-memory object. Untrusted +klass.new(hash)+ callers
1828
- # default to filter those keys. The +@_trusted_init+ ivar is the
1826
+ # protected keys like `sessionToken`, `_rperm` that must populate
1827
+ # the in-memory object. Untrusted `klass.new(hash)` callers
1828
+ # default to filter those keys. The `@_trusted_init` ivar is the
1829
1829
  # signal — see {#initialize} for why we don't use a kwarg.
1830
1830
  o.instance_variable_set(:@_trusted_init, true)
1831
1831
  o.send(:initialize, json)
@@ -2138,13 +2138,13 @@ class Array
2138
2138
  # Parse::Pointer or are a JSON Parse hash. If it is a hash, a Pare::Object will be built from it
2139
2139
  # if it constrains the proper fields. Non-convertible objects will be removed.
2140
2140
  #
2141
- # When +className+ is provided by the caller, it is treated as authoritative
2142
- # — incoming hash +className+ values are ignored. This blocks attacker-
2141
+ # When `className` is provided by the caller, it is treated as authoritative
2142
+ # — incoming hash `className` values are ignored. This blocks attacker-
2143
2143
  # controlled type confusion when this helper is invoked from typed
2144
- # associations (+has_many+, +belongs_to+) that already know the expected
2144
+ # associations (`has_many`, `belongs_to`) that already know the expected
2145
2145
  # class.
2146
2146
  #
2147
- # When +className+ is +nil+ (caller is doing untyped array conversion),
2147
+ # When `className` is `nil` (caller is doing untyped array conversion),
2148
2148
  # the helper falls back to the hash-supplied className for compatibility
2149
2149
  # with raw JSON deserialization callers.
2150
2150
  #
@@ -86,7 +86,7 @@ module Parse
86
86
 
87
87
  # Assign the Parse objectId. Empty / nil values are permitted (Pointer
88
88
  # in unbound state); non-empty values must match {OBJECT_ID_FORMAT}.
89
- # @raise [ArgumentError] when +value+ is a non-empty string that does
89
+ # @raise [ArgumentError] when `value` is a non-empty string that does
90
90
  # not match the format.
91
91
  def id=(value)
92
92
  if value.nil?
@@ -255,9 +255,9 @@ module Parse
255
255
  obj = klass.build(result, parse_class, fetched_keys: top_level_keys, nested_fetched_keys: nested_keys.presence)
256
256
  else
257
257
  # Full fetch - create without partial fetch tracking. Trusted
258
- # hydration: +result+ is the server response body, which
259
- # legitimately carries +createdAt+/+updatedAt+/+sessionToken+
260
- # and other PROTECTED_MASS_ASSIGNMENT_KEYS. The +@_trusted_init+
258
+ # hydration: `result` is the server response body, which
259
+ # legitimately carries `createdAt`/`updatedAt`/`sessionToken`
260
+ # and other PROTECTED_MASS_ASSIGNMENT_KEYS. The `@_trusted_init`
261
261
  # ivar tells {Parse::Object#initialize} to skip the protected-key
262
262
  # filter — see that method for why we don't use a kwarg.
263
263
  obj = klass.allocate
@@ -74,14 +74,14 @@ module Parse
74
74
  # caller did not explicitly opt in via {Parse::Push.allow_broadcast}
75
75
  # or per-instance {#broadcast!}.
76
76
  #
77
- # This is a fail-closed guard against the +to_audience+ /
78
- # +to_audience_id+ class of footguns where a typo, deleted audience,
77
+ # This is a fail-closed guard against the `to_audience` /
78
+ # `to_audience_id` class of footguns where a typo, deleted audience,
79
79
  # or unset-param silently degrades a targeted push into a global one.
80
80
  class BroadcastNotAllowed < StandardError; end
81
81
 
82
82
  # Raised when {#to_audience} or {#to_audience_id} cannot resolve the
83
83
  # requested audience. Previously these methods warned and returned
84
- # +self+, which let the subsequent +send!+ silently broadcast to every
84
+ # `self`, which let the subsequent `send!` silently broadcast to every
85
85
  # Installation. They now raise so typos and renames surface at the
86
86
  # call site instead.
87
87
  class AudienceNotFound < ArgumentError; end
@@ -89,9 +89,9 @@ module Parse
89
89
  # @!attribute [rw] allow_broadcast
90
90
  # Whether {Parse::Push} permits an unconstrained push (no `where`,
91
91
  # no `channels`) to broadcast to every Installation. Defaults to
92
- # +false+ — sending an unconstrained push raises {BroadcastNotAllowed}.
92
+ # `false` — sending an unconstrained push raises {BroadcastNotAllowed}.
93
93
  #
94
- # Set to +true+ at boot for apps that legitimately broadcast (e.g.,
94
+ # Set to `true` at boot for apps that legitimately broadcast (e.g.,
95
95
  # `Parse::Push.allow_broadcast = true`). Or opt in per-instance with
96
96
  # {#broadcast!}, which is auditable in code review.
97
97
  # @return [Boolean]
data/lib/parse/mongodb.rb CHANGED
@@ -28,15 +28,15 @@ module Parse
28
28
  # When writing aggregation pipelines for direct MongoDB queries, use MongoDB's native
29
29
  # field naming conventions:
30
30
  #
31
- # - *Regular fields*: Use camelCase (e.g., +releaseDate+, +playCount+, +firstName+)
32
- # - *Pointer fields*: Use +_p_+ prefix (e.g., +_p_author+, +_p_album+)
33
- # - *Built-in dates*: Use +_created_at+ and +_updated_at+
34
- # - *Field references*: Use +$fieldName+ syntax (e.g., +$releaseDate+, +$_p_author+)
31
+ # - *Regular fields*: Use camelCase (e.g., `releaseDate`, `playCount`, `firstName`)
32
+ # - *Pointer fields*: Use `_p_` prefix (e.g., `_p_author`, `_p_album`)
33
+ # - *Built-in dates*: Use `_created_at` and `_updated_at`
34
+ # - *Field references*: Use `$fieldName` syntax (e.g., `$releaseDate`, `$_p_author`)
35
35
  #
36
36
  # Results are automatically converted to Ruby-friendly format:
37
- # - Field names converted to snake_case (+totalPlays++total_plays+)
38
- # - Custom aggregation results wrapped in +AggregationResult+ for method access
39
- # - Parse documents returned as proper +Parse::Object+ instances
37
+ # - Field names converted to snake_case (`totalPlays``total_plays`)
38
+ # - Custom aggregation results wrapped in `AggregationResult` for method access
39
+ # - Parse documents returned as proper `Parse::Object` instances
40
40
  #
41
41
  # @example Aggregation pipeline with MongoDB field names
42
42
  # pipeline = [
@@ -52,9 +52,9 @@ module Parse
52
52
  # == Date Comparisons
53
53
  #
54
54
  # MongoDB stores dates in UTC. When comparing dates in aggregation pipelines:
55
- # - Use Ruby +Time+ objects for comparisons (automatically converted to BSON dates)
56
- # - Ruby +Date+ objects (without time) are stored as midnight UTC
57
- # - For accurate date-only comparisons, use +Time.utc(year, month, day)+
55
+ # - Use Ruby `Time` objects for comparisons (automatically converted to BSON dates)
56
+ # - Ruby `Date` objects (without time) are stored as midnight UTC
57
+ # - For accurate date-only comparisons, use `Time.utc(year, month, day)`
58
58
  #
59
59
  # @example Date comparison in aggregation
60
60
  # # Compare with a specific UTC time
@@ -1460,7 +1460,7 @@ module Parse
1460
1460
  # @param max_time_ms [Integer, nil] optional server-side time limit in milliseconds.
1461
1461
  # When provided, MongoDB will cancel the query if it exceeds this budget and
1462
1462
  # the driver error is translated to {Parse::MongoDB::ExecutionTimeout}.
1463
- # Pass +nil+ (the default) for no cap.
1463
+ # Pass `nil` (the default) for no cap.
1464
1464
  # @return [Array<Hash>] the raw results from MongoDB
1465
1465
  # @param rewrite_lookups [Boolean, nil] when true (default `nil` --
1466
1466
  # reads `Parse.rewrite_lookups`), auto-rewrite LLM-style $lookup
@@ -1469,16 +1469,16 @@ module Parse
1469
1469
  # @param allow_internal_fields [Boolean] when true, skip the
1470
1470
  # internal-fields denylist check (e.g. for SDK-generated ACL
1471
1471
  # filters produced by {Parse::Query#readable_by_role} and friends
1472
- # that legitimately reference +_rperm+/+_wperm+). The
1473
- # DENIED_OPERATORS walk, forensic-operator-in-+$expr+ check, and
1474
- # internal-field +$+-reference string check all still run.
1475
- # Passed +true+ only from the SDK direct-execution sites that
1472
+ # that legitimately reference `_rperm`/`_wperm`). The
1473
+ # DENIED_OPERATORS walk, forensic-operator-in-`$expr` check, and
1474
+ # internal-field `$`-reference string check all still run.
1475
+ # Passed `true` only from the SDK direct-execution sites that
1476
1476
  # build their pipeline entirely from {Parse::Query#compile_where}:
1477
- # +Parse::Query#results_direct+, +#first_direct+ (via
1478
- # +results_direct+), +#count_direct+, +#distinct_direct+,
1479
- # +#atlas_search+ builder-block, and the two +#group_by_*+ direct
1480
- # paths. The Agent MCP tool path and +Aggregation#execute_direct!+
1481
- # keep the default +false+ so attacker-controlled or user-supplied
1477
+ # `Parse::Query#results_direct`, `#first_direct` (via
1478
+ # `results_direct`), `#count_direct`, `#distinct_direct`,
1479
+ # `#atlas_search` builder-block, and the two `#group_by_*` direct
1480
+ # paths. The Agent MCP tool path and `Aggregation#execute_direct!`
1481
+ # keep the default `false` so attacker-controlled or user-supplied
1482
1482
  # aggregate stages cannot reach internal columns.
1483
1483
  # @param session_token [String, nil] when provided, the SDK
1484
1484
  # resolves the token to the requesting user + role subscription
@@ -1582,7 +1582,7 @@ module Parse
1582
1582
  # are SDK-generated (not attacker-controlled), so no
1583
1583
  # re-validation is needed before they're handed to MongoDB.
1584
1584
  if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
1585
- pipeline = [acl_stage] + pipeline
1585
+ pipeline = prepend_or_fold_acl_match(pipeline, acl_stage)
1586
1586
  end
1587
1587
  pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution)
1588
1588
 
@@ -1673,6 +1673,64 @@ module Parse
1673
1673
  raise
1674
1674
  end
1675
1675
 
1676
+ # Inject the scoped ACL `$match` at the front of a pipeline — UNLESS
1677
+ # the first stage is `$geoNear`, which MongoDB requires to be
1678
+ # pipeline stage 0. In that case fold the ACL predicate into
1679
+ # `$geoNear.query` (a candidate-document pre-filter, semantically
1680
+ # equivalent to a leading `$match`) so the stage-0 invariant holds
1681
+ # and the scoped geo query still enforces `_rperm`.
1682
+ #
1683
+ # A scoped `geo_near` previously failed CLOSED here: the prepended
1684
+ # `$match` pushed `$geoNear` off stage 0 and MongoDB rejected the
1685
+ # whole pipeline. Post-fetch redaction (protectedFields / sub-doc /
1686
+ # internal-field) runs regardless, so folding loses no enforcement.
1687
+ #
1688
+ # @param pipeline [Array<Hash>]
1689
+ # @param acl_stage [Hash] `{ "$match" => <predicate> }` from
1690
+ # {Parse::ACLScope.match_stage_for}.
1691
+ # @return [Array<Hash>] a new pipeline; caller stages are not mutated.
1692
+ def prepend_or_fold_acl_match(pipeline, acl_stage)
1693
+ geo_key = geo_near_stage_key(pipeline.first)
1694
+ return [acl_stage] + pipeline unless geo_key
1695
+
1696
+ match_pred = acl_stage["$match"] || acl_stage[:$match]
1697
+ geo = pipeline.first[geo_key].dup
1698
+ # Match the stage's own key style: reuse an existing `query` key of
1699
+ # either type, else follow the `$geoNear` key's type (string stage
1700
+ # → "query", symbol stage → :query). The Mongo driver normalizes
1701
+ # either way, but keeping one style avoids a duplicate query key.
1702
+ q_key =
1703
+ if geo.key?("query") then "query"
1704
+ elsif geo.key?(:query) then :query
1705
+ elsif geo_key.is_a?(String) then "query"
1706
+ else :query
1707
+ end
1708
+ existing = geo[q_key]
1709
+ geo[q_key] =
1710
+ if existing.is_a?(Hash) && !existing.empty?
1711
+ # `existing` is still the caller's own `$geoNear.query` hash
1712
+ # (the outer `.dup` above is shallow). Embed a copy, not the
1713
+ # original, so the folded pipeline and the caller's pipeline
1714
+ # don't share a mutable hash — honoring the "caller stages are
1715
+ # not mutated" contract in both directions.
1716
+ { "$and" => [existing.dup, match_pred] }
1717
+ else
1718
+ match_pred
1719
+ end
1720
+ new_first = pipeline.first.dup
1721
+ new_first[geo_key] = geo
1722
+ [new_first] + pipeline[1..]
1723
+ end
1724
+
1725
+ # @return [Symbol, String, nil] the `$geoNear` key (symbol or string
1726
+ # form) if `stage` is a `$geoNear` stage, else nil.
1727
+ def geo_near_stage_key(stage)
1728
+ return nil unless stage.is_a?(Hash)
1729
+ return :$geoNear if stage.key?(:$geoNear)
1730
+ return "$geoNear" if stage.key?("$geoNear")
1731
+ nil
1732
+ end
1733
+
1676
1734
  # Execute a `$geoNear` aggregation against a collection, returning
1677
1735
  # documents sorted by proximity to `near` along with their computed
1678
1736
  # distance. `$geoNear` is the aggregation-pipeline analogue of
@@ -2048,10 +2106,10 @@ module Parse
2048
2106
  end
2049
2107
 
2050
2108
  # Convert a raw MongoDB aggregation row, coercing values (BSON ObjectIds,
2051
- # dates, nested documents) but preserving all field names including +_id+.
2052
- # Unlike {.convert_document_to_parse}, this does NOT rename +_id+ to
2053
- # +objectId+, because aggregation +$group+ stages reuse +_id+ as the
2054
- # group key (e.g. a pointer string like +"Workspace$abc"+) rather than as a
2109
+ # dates, nested documents) but preserving all field names including `_id`.
2110
+ # Unlike {.convert_document_to_parse}, this does NOT rename `_id` to
2111
+ # `objectId`, because aggregation `$group` stages reuse `_id` as the
2112
+ # group key (e.g. a pointer string like `"Workspace$abc"`) rather than as a
2055
2113
  # Parse object identifier.
2056
2114
  #
2057
2115
  # @param doc [Hash] a raw MongoDB aggregation result row
@@ -2379,7 +2437,7 @@ module Parse
2379
2437
  # DENIED_OPERATORS walk still runs. Intended only for callers
2380
2438
  # that built the pipeline via {Parse::Query}'s own constraint
2381
2439
  # DSL (e.g. {Parse::Query#readable_by_role}); raw user-supplied
2382
- # pipelines (Agent MCP tools) must keep the default +false+.
2440
+ # pipelines (Agent MCP tools) must keep the default `false`.
2383
2441
  #
2384
2442
  # Public for testability and for callers that want to validate
2385
2443
  # input before forwarding to {.find} / {.aggregate}.
@@ -282,10 +282,10 @@ module Parse
282
282
  # @param node [Hash, Array, Object] the structure to walk.
283
283
  # @param allow_internal_fields [Boolean] when true, skip the
284
284
  # {INTERNAL_FIELDS_DENYLIST} check (e.g. for SDK-generated ACL
285
- # filters that legitimately reference +_rperm+/+_wperm+ via
285
+ # filters that legitimately reference `_rperm`/`_wperm` via
286
286
  # {Parse::Query#readable_by_role} and friends). The
287
287
  # {DENIED_OPERATORS} walk and forensic-operator gating still
288
- # apply. Default +false+ for callers that forward raw,
288
+ # apply. Default `false` for callers that forward raw,
289
289
  # user-influenced pipelines (e.g. Agent MCP tools).
290
290
  # @raise [Error] if a denied operator is found at any depth.
291
291
  # @return [true]
@@ -2550,21 +2550,21 @@ module Parse
2550
2550
 
2551
2551
  # @!visibility private
2552
2552
  # Shared helpers for the four ACL constraint subclasses
2553
- # (+:ACL.readable_by+, +:ACL.readable_by_role+, +:ACL.writable_by+,
2554
- # +:ACL.writable_by_role+). Collects a list of permission strings
2553
+ # (`:ACL.readable_by`, `:ACL.readable_by_role`, `:ACL.writable_by`,
2554
+ # `:ACL.writable_by_role`). Collects a list of permission strings
2555
2555
  # from a caller-supplied value of User/Role/Pointer/Array/String,
2556
2556
  # using {Parse::Role.all_for_user} (user inputs) and
2557
2557
  # {Parse::Role#all_parent_role_names} (role inputs) so the
2558
2558
  # traversal walks the inheritance direction Parse Server actually
2559
- # enforces. Prior implementations inlined +role.all_child_roles+,
2559
+ # enforces. Prior implementations inlined `role.all_child_roles`,
2560
2560
  # which traverses the wrong direction and over-grants.
2561
2561
  module ACLPermissions
2562
2562
  module_function
2563
2563
 
2564
- # Expand a +:ACL.readable_by+ / +:ACL.writable_by+ value into a
2565
- # permission-string array. Uses explicit +is_a?+ calls (rather
2566
- # than +case/when+) so callers that pass duck-typed mocks with
2567
- # overridden +is_a?+ — common in the constraint test suite —
2564
+ # Expand a `:ACL.readable_by` / `:ACL.writable_by` value into a
2565
+ # permission-string array. Uses explicit `is_a?` calls (rather
2566
+ # than `case/when`) so callers that pass duck-typed mocks with
2567
+ # overridden `is_a?` — common in the constraint test suite —
2568
2568
  # continue to route correctly.
2569
2569
  # @param value [Parse::User, Parse::Role, Parse::Pointer, String, Array]
2570
2570
  # @return [Array<String>]
@@ -2612,9 +2612,9 @@ module Parse
2612
2612
  str == "public" ? "*" : str
2613
2613
  end
2614
2614
 
2615
- # Expand a +:ACL.readable_by_role+ / +:ACL.writable_by_role+ value
2615
+ # Expand a `:ACL.readable_by_role` / `:ACL.writable_by_role` value
2616
2616
  # into a permission-string array. Differs from {.collect} by
2617
- # auto-prefixing bare strings with +"role:"+ and refusing
2617
+ # auto-prefixing bare strings with `"role:"` and refusing
2618
2618
  # non-role arguments.
2619
2619
  # @param value [Parse::Role, Parse::Pointer, String, Array]
2620
2620
  # @return [Array<String>]
@@ -2686,18 +2686,18 @@ module Parse
2686
2686
 
2687
2687
  # Compile a final permission-string array into an aggregation
2688
2688
  # pipeline match-stage on the requested permission field. The
2689
- # +$exists: false+ branch is appended by {Parse::ACL.read_predicate}
2690
- # / {Parse::ACL.write_predicate} so a missing +_rperm+/+_wperm+
2689
+ # `$exists: false` branch is appended by {Parse::ACL.read_predicate}
2690
+ # / {Parse::ACL.write_predicate} so a missing `_rperm`/`_wperm`
2691
2691
  # (treated as public by Parse Server) still matches.
2692
2692
  # @param permissions [Array<String>]
2693
- # @param field [String] +"_rperm"+ or +"_wperm"+.
2693
+ # @param field [String] `"_rperm"` or `"_wperm"`.
2694
2694
  # @return [Hash] aggregation-pipeline wrapper compatible with
2695
2695
  # {Parse::Query}'s constraint-build contract.
2696
2696
  # @param strict [Boolean] when true, build an EXACT match: suppress
2697
- # both the implicit public +"*"+ grant AND the missing-field
2698
- # (+$exists: false+) branch, so only rows whose +_rperm+/+_wperm+
2699
- # literally contains one of +permissions+ match. Used by the
2700
- # +readable_by(..., strict: true)+ / +readable_by_exact+ surface.
2697
+ # both the implicit public `"*"` grant AND the missing-field
2698
+ # (`$exists: false`) branch, so only rows whose `_rperm`/`_wperm`
2699
+ # literally contains one of `permissions` match. Used by the
2700
+ # `readable_by(..., strict: true)` / `readable_by_exact` surface.
2701
2701
  def pipeline(permissions, field:, strict: false)
2702
2702
  deduped = permissions.compact.reject(&:empty?).uniq
2703
2703
  if deduped.empty?
@@ -2712,9 +2712,9 @@ module Parse
2712
2712
  end
2713
2713
 
2714
2714
  # @!visibility private
2715
- # Whether a +readable_by+ / +writable_by+ value expresses "no
2716
- # permissions" (master-key-only): +nil+, an empty Array, the String
2717
- # +"none"+, or the Symbol +:none+. These map to {.empty_pipeline}.
2715
+ # Whether a `readable_by` / `writable_by` value expresses "no
2716
+ # permissions" (master-key-only): `nil`, an empty Array, the String
2717
+ # `"none"`, or the Symbol `:none`. These map to {.empty_pipeline}.
2718
2718
  def empty_intent?(value)
2719
2719
  return true if value.nil?
2720
2720
  return true if value == "none" || value == :none
@@ -2724,11 +2724,11 @@ module Parse
2724
2724
 
2725
2725
  # @!visibility private
2726
2726
  # The match for "no permissions": an explicit empty array. A missing
2727
- # +_rperm+/+_wperm+ is treated by Parse Server as PUBLIC — the
2728
- # opposite of master-only — so it must NOT match here. +$eq: []+
2727
+ # `_rperm`/`_wperm` is treated by Parse Server as PUBLIC — the
2728
+ # opposite of master-only — so it must NOT match here. `$eq: []`
2729
2729
  # already excludes a missing field (missing != []); the +$exists:
2730
2730
  # true+ guard documents that intent.
2731
- # @param field [String] +"_rperm"+ or +"_wperm"+.
2731
+ # @param field [String] `"_rperm"` or `"_wperm"`.
2732
2732
  def empty_pipeline(field:)
2733
2733
  { "__aggregation_pipeline" => [
2734
2734
  { "$match" => { field => { "$exists" => true, "$eq" => [] } } },
@@ -2736,11 +2736,11 @@ module Parse
2736
2736
  end
2737
2737
 
2738
2738
  # @!visibility private
2739
- # Permission keys for a +not_readable_by+ / +not_writable_by+ value:
2739
+ # Permission keys for a `not_readable_by` / `not_writable_by` value:
2740
2740
  # the expanded grant set (user→roles, role→parent roles) PLUS the
2741
- # public +"*"+ wildcard. A public row is readable/writable by everyone,
2741
+ # public `"*"` wildcard. A public row is readable/writable by everyone,
2742
2742
  # so it must be EXCLUDED from a "not readable/writable by X" result —
2743
- # hence +"*"+ is added to the +$nin+ set. Returns +[]+ for an
2743
+ # hence `"*"` is added to the `$nin` set. Returns `[]` for an
2744
2744
  # empty-intent value (no negation constraint is applied).
2745
2745
  # @return [Array<String>]
2746
2746
  def collect_for_negation(value)
@@ -2831,7 +2831,7 @@ module Parse
2831
2831
  register :readable_by
2832
2832
 
2833
2833
  # @return [Boolean] whether to compile an EXACT match (suppress the
2834
- # implicit public +"*"+ grant and the missing-field branch).
2834
+ # implicit public `"*"` grant and the missing-field branch).
2835
2835
  # Overridden by {ACLReadableByExactConstraint}.
2836
2836
  def strict?
2837
2837
  false
@@ -2855,10 +2855,10 @@ module Parse
2855
2855
  end
2856
2856
 
2857
2857
  # Strict variant of {ACLReadableByConstraint}: matches ONLY rows whose
2858
- # +_rperm+ literally contains one of the resolved permissions — no
2859
- # implicit public +"*"+ and no missing-+_rperm+ (public-by-absence) rows.
2860
- # Reached via +Query#readable_by(value, strict: true)+ or the
2861
- # +:ACL.readable_by_exact+ symbol operator. Use this for ownership /
2858
+ # `_rperm` literally contains one of the resolved permissions — no
2859
+ # implicit public `"*"` and no missing-`_rperm` (public-by-absence) rows.
2860
+ # Reached via `Query#readable_by(value, strict: true)` or the
2861
+ # `:ACL.readable_by_exact` symbol operator. Use this for ownership /
2862
2862
  # security audits ("which rows explicitly grant this principal") rather
2863
2863
  # than access simulation ("what can this principal read").
2864
2864
  class ACLReadableByExactConstraint < ACLReadableByConstraint
@@ -3185,23 +3185,23 @@ module Parse
3185
3185
  end
3186
3186
  end
3187
3187
 
3188
- # @deprecated Thin alias of {ACLReadableByConstraint}. The +:readable_by+
3188
+ # @deprecated Thin alias of {ACLReadableByConstraint}. The `:readable_by`
3189
3189
  # operator is registered by {ACLReadableByConstraint}; this constant is
3190
3190
  # retained only so any code referencing it keeps working. The previous
3191
3191
  # standalone implementation (no role expansion, no implicit public
3192
- # +"*"+, divergent empty-ACL shape) has been removed — it never backed
3193
- # the +:readable_by+ operator and silently disagreed with it.
3192
+ # `"*"`, divergent empty-ACL shape) has been removed — it never backed
3193
+ # the `:readable_by` operator and silently disagreed with it.
3194
3194
  class ReadableByConstraint < ACLReadableByConstraint
3195
3195
  end
3196
3196
 
3197
3197
  # @deprecated Alias of {ACLWritableByConstraint}. The British-spelled
3198
- # +:writeable_by+ operator now resolves to the SAME public-inclusive,
3199
- # role-expanding implementation as +:writable_by+ — previously it was a
3198
+ # `:writeable_by` operator now resolves to the SAME public-inclusive,
3199
+ # role-expanding implementation as `:writable_by` — previously it was a
3200
3200
  # separate, strict, non-expanding constraint, so the one-letter spelling
3201
3201
  # difference silently changed query semantics. For the old exact-match
3202
3202
  # behavior (no implicit public, no role expansion, no missing-field),
3203
- # use +readable_by(..., strict: true)+ / +writable_by(..., strict: true)+
3204
- # or the +:writable_by_exact+ operator.
3203
+ # use `readable_by(..., strict: true)` / `writable_by(..., strict: true)`
3204
+ # or the `:writable_by_exact` operator.
3205
3205
  class WriteableByConstraint < ACLWritableByConstraint
3206
3206
  register :writeable_by
3207
3207
  end
@@ -3223,7 +3223,7 @@ module Parse
3223
3223
  #
3224
3224
  # @note "Not readable by X" excludes rows readable by X *directly*, *via
3225
3225
  # any role X inherits*, AND *publicly* — so a User value expands its
3226
- # roles and the public +"*"+ is always added to the exclusion set.
3226
+ # roles and the public `"*"` is always added to the exclusion set.
3227
3227
  # @note This constraint uses aggregation pipeline because Parse Server
3228
3228
  # restricts direct queries on the internal _rperm field.
3229
3229
  class NotReadableByConstraint < Constraint