parse-stack-next 4.5.0 → 5.0.1

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
@@ -0,0 +1,102 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "model"
5
+
6
+ module Parse
7
+ # Wraps a dense numeric embedding stored on a Parse object. Backs the
8
+ # `:vector` property data type and the `embed` DSL. The value is just
9
+ # an array of Floats — `Parse::Vector` adds dimension awareness,
10
+ # finite-value validation, and JSON serialization helpers so the
11
+ # provider/index plumbing can rely on a single concrete shape.
12
+ #
13
+ # @example
14
+ # class Document < Parse::Object
15
+ # property :embedding, :vector, dimensions: 1536,
16
+ # provider: :openai,
17
+ # model: "text-embedding-3-small",
18
+ # similarity: :cosine
19
+ # end
20
+ #
21
+ # doc = Document.new(embedding: Array.new(1536) { rand })
22
+ # doc.embedding # => #<Parse::Vector dims=1536>
23
+ # doc.embedding.to_a # => [0.123, 0.456, ...]
24
+ class Vector
25
+ include Enumerable
26
+
27
+ # Maximum dimensions a Parse::Vector will accept. Atlas Vector Search
28
+ # caps individual vector indexes at 8192 dims as of MongoDB 7.0; we
29
+ # keep some headroom but still refuse pathological inputs that would
30
+ # blow up memory.
31
+ MAX_DIMENSIONS = 16384
32
+
33
+ # @return [Array<Float>] the underlying float array
34
+ attr_reader :values
35
+
36
+ # @param values [Array, Parse::Vector] dense numeric vector
37
+ # @raise [ArgumentError] if any element is not finite numeric
38
+ def initialize(values)
39
+ values = values.values if values.is_a?(Parse::Vector)
40
+ unless values.is_a?(Array)
41
+ raise ArgumentError, "[Parse::Vector] expected Array, got #{values.class}."
42
+ end
43
+ if values.length > MAX_DIMENSIONS
44
+ raise ArgumentError,
45
+ "[Parse::Vector] refusing #{values.length}-dim vector; max #{MAX_DIMENSIONS}."
46
+ end
47
+ @values = values.map do |x|
48
+ unless x.is_a?(Numeric) && x.respond_to?(:finite?) && x.finite?
49
+ raise ArgumentError,
50
+ "[Parse::Vector] all elements must be finite Numeric (got #{x.inspect})."
51
+ end
52
+ x.to_f
53
+ end.freeze
54
+ end
55
+
56
+ # @return [Integer] number of dimensions
57
+ def dimensions
58
+ @values.length
59
+ end
60
+ alias_method :length, :dimensions
61
+ alias_method :size, :dimensions
62
+
63
+ # @return [Array<Float>] the underlying float array
64
+ def to_a
65
+ @values.dup
66
+ end
67
+
68
+ # @return [Array<Float>] passes the float array through as JSON
69
+ # MongoDB / Parse server store this as a plain BSON array.
70
+ def as_json(*)
71
+ @values
72
+ end
73
+
74
+ # @return [String] JSON representation
75
+ def to_json(*opts)
76
+ @values.to_json(*opts)
77
+ end
78
+
79
+ def each(&block)
80
+ @values.each(&block)
81
+ end
82
+
83
+ # @return [Boolean] equality by element-wise comparison
84
+ def ==(other)
85
+ case other
86
+ when Parse::Vector then @values == other.values
87
+ when Array then @values == other
88
+ else false
89
+ end
90
+ end
91
+ alias_method :eql?, :==
92
+
93
+ def hash
94
+ @values.hash
95
+ end
96
+
97
+ # @!visibility private
98
+ def inspect
99
+ "#<Parse::Vector dims=#{dimensions}>"
100
+ end
101
+ end
102
+ end
data/lib/parse/mongodb.rb CHANGED
@@ -384,6 +384,7 @@ module Parse
384
384
  @enabled = false
385
385
  @uri = nil
386
386
  @database = nil
387
+ remove_instance_variable(:@gem_available) if defined?(@gem_available)
387
388
  reset_writer!
388
389
  end
389
390
 
@@ -974,7 +975,7 @@ module Parse
974
975
  ROLE_GRAPH_ID_RE = /\A[A-Za-z0-9_\-]{1,64}\z/
975
976
 
976
977
  # Resolve every role name a user inherits via a single
977
- # `$graphLookup` aggregation against the Parse role-membership and
978
+ # `$graphLookup` aggregation against the Parse role-subscription and
978
979
  # role-inheritance join tables.
979
980
  #
980
981
  # This is the mongo-direct fast path that {Parse::Role.all_for_user}
@@ -995,7 +996,7 @@ module Parse
995
996
  # If `_Join:roles:_Role` doesn't exist (the app uses flat roles
996
997
  # without inheritance), MongoDB treats the missing collection as
997
998
  # empty and `$graphLookup` returns no parents — the result collapses
998
- # to direct memberships only, matching the Parse-Server-backed walk.
999
+ # to direct subscriptions only, matching the Parse-Server-backed walk.
999
1000
  #
1000
1001
  # ## Authorization contract
1001
1002
  #
@@ -1019,7 +1020,7 @@ module Parse
1019
1020
  #
1020
1021
  # ## Return-value contract
1021
1022
  # - `Set<String>` on success (possibly empty if the user has no
1022
- # direct memberships).
1023
+ # direct subscriptions).
1023
1024
  # - `nil` when the fast path is unavailable (mongo gem missing,
1024
1025
  # {Parse::MongoDB.available?} false). Callers fall back to the
1025
1026
  # Parse-Server N+1 walk.
@@ -1367,11 +1368,11 @@ module Parse
1367
1368
  "from" => "_Join:users:_Role",
1368
1369
  "localField" => "ids",
1369
1370
  "foreignField" => "owningId",
1370
- "as" => "memberships",
1371
+ "as" => "subscriptions",
1371
1372
  } },
1372
1373
  { "$project" => {
1373
1374
  "_id" => 0,
1374
- "user_id_candidates" => "$memberships.relatedId",
1375
+ "user_id_candidates" => "$subscriptions.relatedId",
1375
1376
  } },
1376
1377
  # Filter tombstoned _User rows AND project only `_id` server-side
1377
1378
  # via pipeline-form $lookup (3.6+). Without this, a role with N
@@ -1480,7 +1481,7 @@ module Parse
1480
1481
  # keep the default +false+ so attacker-controlled or user-supplied
1481
1482
  # aggregate stages cannot reach internal columns.
1482
1483
  # @param session_token [String, nil] when provided, the SDK
1483
- # resolves the token to the requesting user + role membership
1484
+ # resolves the token to the requesting user + role subscription
1484
1485
  # (via {Parse::AtlasSearch::Session}) and prepends an
1485
1486
  # `_rperm` `$match` stage to the pipeline so the result set
1486
1487
  # simulates Parse Server's row-level ACL enforcement. This
@@ -1499,6 +1500,28 @@ module Parse
1499
1500
  # `session_token:` nor `master: true` is supplied and
1500
1501
  # {Parse::ACLScope.require_session_token} is enabled.
1501
1502
  def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil)
1503
+ # AS::N envelope. Payload is intentionally metadata-only —
1504
+ # `stage_count`, `stage_types`, `collection`, `scope`,
1505
+ # `result_count`, `max_time_ms`, `read_preference`. Pipeline
1506
+ # bodies are NOT included: they routinely embed user-id
1507
+ # strings, tenant identifiers, search terms, and other PII
1508
+ # that has no business in a log line or an APM span. The
1509
+ # `parse.mongodb.role_graph` notification (emitted lower in
1510
+ # this module) nests as a child event when role expansion
1511
+ # runs inside the surrounding aggregate. `result_count` and
1512
+ # `scope` are seeded nil so subscribers see a stable key set
1513
+ # even on the raise path (where the block exits before either
1514
+ # is written).
1515
+ instrument_payload = {
1516
+ collection: collection_name,
1517
+ stage_count: pipeline.is_a?(Array) ? pipeline.size : 0,
1518
+ stage_types: __extract_stage_types(pipeline),
1519
+ max_time_ms: max_time_ms,
1520
+ read_preference: read_preference&.to_s,
1521
+ scope: nil,
1522
+ result_count: nil,
1523
+ }
1524
+ ActiveSupport::Notifications.instrument("parse.mongodb.aggregate", instrument_payload) do |payload|
1502
1525
  # Resolve auth kwargs into a Parse::ACLScope::Resolution. The
1503
1526
  # call MUTATES the temporary kwargs hash (popping the auth
1504
1527
  # entries) before the resolution; we package them into a hash
@@ -1511,6 +1534,7 @@ module Parse
1511
1534
  acl_role: acl_role,
1512
1535
  }.compact
1513
1536
  resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate)
1537
+ payload[:scope] = __scope_label(resolution)
1514
1538
 
1515
1539
  # Validate BEFORE rewrite so the security denylist is applied to the
1516
1540
  # caller's original pipeline (which an attacker controls), not to
@@ -1624,7 +1648,9 @@ module Parse
1624
1648
  Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
1625
1649
  end
1626
1650
 
1651
+ payload[:result_count] = results.size
1627
1652
  results
1653
+ end
1628
1654
  rescue => e
1629
1655
  raise_if_timeout!(e, collection_name, max_time_ms)
1630
1656
  raise
@@ -1768,9 +1794,33 @@ module Parse
1768
1794
  # $where, $function, or $accumulator at any depth.
1769
1795
  # @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds max_time_ms
1770
1796
  def find(collection_name, filter = {}, **options)
1797
+ max_time_ms = options.delete(:max_time_ms)
1798
+ # Metadata-only AS::N payload: collection, presence-of-filter
1799
+ # (NOT body), projection keys (column names, not values), limit,
1800
+ # max_time_ms, result_count. Filter / projection bodies are
1801
+ # excluded because they routinely embed user-id strings,
1802
+ # tenant IDs, and other PII that has no business in a log line
1803
+ # or a span. The `find` payload deliberately has no `:scope`
1804
+ # field — `Parse::MongoDB.find` takes no ACL kwargs, so there
1805
+ # is no resolution to label. Shared subscribers that handle
1806
+ # both event names must treat `payload[:scope]` as optional.
1807
+ # `result_count` is seeded nil so subscribers see a stable key
1808
+ # set even on the raise path.
1809
+ projection_keys =
1810
+ if options[:projection].is_a?(Hash)
1811
+ options[:projection].keys.map(&:to_s)
1812
+ end
1813
+ instrument_payload = {
1814
+ collection: collection_name,
1815
+ has_filter: filter.is_a?(Hash) && !filter.empty?,
1816
+ projection_keys: projection_keys,
1817
+ limit: options[:limit],
1818
+ max_time_ms: max_time_ms,
1819
+ result_count: nil,
1820
+ }
1821
+ ActiveSupport::Notifications.instrument("parse.mongodb.find", instrument_payload) do |payload|
1771
1822
  allow_internal_fields = options.delete(:allow_internal_fields) || false
1772
1823
  assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields)
1773
- max_time_ms = options.delete(:max_time_ms)
1774
1824
  cursor = collection(collection_name).find(filter)
1775
1825
  explicit_limit = options.key?(:limit)
1776
1826
  applied_default_limit = false
@@ -1801,7 +1851,9 @@ module Parse
1801
1851
  "unbounded behavior."
1802
1852
  end
1803
1853
 
1854
+ payload[:result_count] = results.size
1804
1855
  results
1856
+ end
1805
1857
  rescue => e
1806
1858
  raise_if_timeout!(e, collection_name, max_time_ms)
1807
1859
  raise
@@ -1981,7 +2033,7 @@ module Parse
1981
2033
  # dates, nested documents) but preserving all field names including +_id+.
1982
2034
  # Unlike {.convert_document_to_parse}, this does NOT rename +_id+ to
1983
2035
  # +objectId+, because aggregation +$group+ stages reuse +_id+ as the
1984
- # group key (e.g. a pointer string like +"Team$abc"+) rather than as a
2036
+ # group key (e.g. a pointer string like +"Workspace$abc"+) rather than as a
1985
2037
  # Parse object identifier.
1986
2038
  #
1987
2039
  # @param doc [Hash] a raw MongoDB aggregation result row
@@ -2060,6 +2112,36 @@ module Parse
2060
2112
 
2061
2113
  private
2062
2114
 
2115
+ # Cardinality cap on the `stage_types` payload field. A
2116
+ # pathological caller sending a 10k-stage pipeline shouldn't
2117
+ # be able to bloat every AS::N subscriber's log line.
2118
+ INSTRUMENT_STAGE_TYPES_LIMIT = 32
2119
+
2120
+ # Extract the top-level operator name from each pipeline stage
2121
+ # (e.g. `["$match", "$lookup", "$project"]`). Returns an empty
2122
+ # array on anything non-Array; non-Hash entries become `nil`
2123
+ # and are pruned. Capped at {INSTRUMENT_STAGE_TYPES_LIMIT}.
2124
+ def __extract_stage_types(pipeline)
2125
+ return [] unless pipeline.is_a?(Array)
2126
+ types = pipeline.first(INSTRUMENT_STAGE_TYPES_LIMIT).map do |stage|
2127
+ stage.is_a?(Hash) ? stage.keys.first.to_s : nil
2128
+ end
2129
+ types.compact
2130
+ end
2131
+
2132
+ # Map a {Parse::ACLScope::Resolution} to a stable, low-cardinality
2133
+ # scope label for the AS::N payload. Four values:
2134
+ # `:master` (master-key path), `:user` (session-token with a
2135
+ # resolved user_id, OR `acl_user:`), `:role` (`acl_role:` —
2136
+ # session mode but no user_id), `:anon` (public — neither
2137
+ # token nor master supplied).
2138
+ def __scope_label(resolution)
2139
+ return :anon if resolution.nil?
2140
+ return :master if resolution.master?
2141
+ return :anon if resolution.public?
2142
+ resolution.user_id ? :user : :role
2143
+ end
2144
+
2063
2145
  # MongoDB error code for MaxTimeMSExpired
2064
2146
  MONGO_MAX_TIME_MS_EXPIRED_CODE = 50
2065
2147
 
@@ -179,15 +179,42 @@ module Parse
179
179
  # Top-level pipeline stages permitted by the strict validator. The
180
180
  # set covers Parse-Stack's own aggregation use, plus Atlas Search
181
181
  # entry points (`$search`, `$searchMeta`, `$listSearchIndexes`) so
182
- # that `Parse::AtlasSearch` calls do not break.
182
+ # that `Parse::AtlasSearch` calls do not break. `$vectorSearch` is
183
+ # included for `Parse::VectorSearch` — like `$search`, it is a
184
+ # read-only Atlas index stage and must be the FIRST stage of the
185
+ # pipeline (Atlas refuses it otherwise).
183
186
  ALLOWED_STAGES = %w[
184
187
  $match $group $sort $project $limit $skip $unwind $lookup
185
188
  $count $addFields $set $unset $bucket $bucketAuto $facet
186
189
  $sample $sortByCount $replaceRoot $replaceWith $redact
187
190
  $graphLookup $unionWith
188
- $search $searchMeta $listSearchIndexes
191
+ $search $searchMeta $listSearchIndexes $vectorSearch
189
192
  ].freeze
190
193
 
194
+ # Atlas operators that are valid only as the FIRST stage of a
195
+ # pipeline (Atlas refuses them anywhere else). They are present in
196
+ # {ALLOWED_STAGES} so the SDK's own modules — `Parse::AtlasSearch`
197
+ # and `Parse::VectorSearch` — can emit them; both of those modules
198
+ # bypass {validate_pipeline!} and build their pipelines internally.
199
+ # Caller-supplied pipelines (e.g. through `Parse::Agent::Tools.aggregate`)
200
+ # must NOT include these stages: the Agent's tenant-scope `$match`
201
+ # prepend would push them off stage 0, and the proper agent surface
202
+ # for full-text and vector search is the dedicated
203
+ # `atlas_search` / `semantic_search` tools, not raw aggregate.
204
+ STAGE0_ONLY_ATLAS_STAGES = %w[
205
+ $search $searchMeta $vectorSearch $listSearchIndexes
206
+ ].freeze
207
+
208
+ # Cap on the length of a caller-supplied `$regex` (or the `regex:`
209
+ # field inside `$regexMatch` / `$regexFind` / `$regexFindAll`)
210
+ # pattern string. ReDoS protection: doesn't catch every pathological
211
+ # pattern (small patterns like `(a+)+$` can still backtrack
212
+ # catastrophically), but caps the worst class of caller-shipped
213
+ # patterns and stops the "1MB regex" denial-of-service shape that an
214
+ # attacker could send through `vector_filter:` / `filter:` /
215
+ # `where:`. Legitimate Parse-Server queries are well under this.
216
+ MAX_REGEX_PATTERN_LENGTH = 512
217
+
191
218
  # Cap on number of top-level stages in a strict-validated pipeline.
192
219
  MAX_PIPELINE_STAGES = 20
193
220
 
@@ -497,6 +524,36 @@ module Parse
497
524
  reason: :denied_internal_field,
498
525
  )
499
526
  end
527
+ # Cap caller-supplied regex pattern length. Catches the two
528
+ # shapes Mongo accepts: the find-form `{ field: { $regex: "..." } }`
529
+ # (key == "$regex", value a String), and the aggregation-form
530
+ # `{ $regexMatch: { input: ..., regex: "..." } }` (key ==
531
+ # "$regexMatch"/"$regexFind"/"$regexFindAll", value a Hash with
532
+ # a "regex"/"pattern" String inside). Stops a multi-KB pattern
533
+ # from reaching MongoDB regardless of where in the pipeline it
534
+ # appears.
535
+ if key_str == "$regex" && value.is_a?(String) && value.bytesize > MAX_REGEX_PATTERN_LENGTH
536
+ raise Error.new(
537
+ "SECURITY: $regex pattern exceeds #{MAX_REGEX_PATTERN_LENGTH} bytes " \
538
+ "(got #{value.bytesize}). Long caller-supplied regex patterns are a " \
539
+ "ReDoS vector; refuse caller-supplied regexes longer than this cap.",
540
+ stage: stage_idx,
541
+ operator: "$regex",
542
+ reason: :regex_pattern_too_long,
543
+ )
544
+ end
545
+ if %w[$regexMatch $regexFind $regexFindAll].include?(key_str) && value.is_a?(Hash)
546
+ pat = value["regex"] || value[:regex] || value["pattern"] || value[:pattern]
547
+ if pat.is_a?(String) && pat.bytesize > MAX_REGEX_PATTERN_LENGTH
548
+ raise Error.new(
549
+ "SECURITY: #{key_str} regex pattern exceeds #{MAX_REGEX_PATTERN_LENGTH} bytes " \
550
+ "(got #{pat.bytesize}). Refuse caller-supplied regexes longer than this cap.",
551
+ stage: stage_idx,
552
+ operator: key_str,
553
+ reason: :regex_pattern_too_long,
554
+ )
555
+ end
556
+ end
500
557
  child_inside_expr = inside_expr || key_str == "$expr"
501
558
  if child_inside_expr && FORENSIC_OPERATORS.include?(key_str)
502
559
  raise Error.new(
@@ -1915,7 +1915,7 @@ module Parse
1915
1915
  end
1916
1916
 
1917
1917
  # Equivalent to the `$geoWithin` Parse query operation with `$centerSphere`
1918
- # subconstraint. Filters a {Parse::GeoPoint} column by membership in a
1918
+ # subconstraint. Filters a {Parse::GeoPoint} column by subscription in a
1919
1919
  # circular region defined by a center point and a radius. Unlike
1920
1920
  # `:field.near => geopoint.max_*(N)`, this constraint does NOT order
1921
1921
  # results by distance, which makes it cheap and composable inside `$or`
@@ -2056,20 +2056,22 @@ module Parse
2056
2056
  ].freeze
2057
2057
 
2058
2058
  def coerce_to_geojson(value)
2059
+ # Ruby hash patterns only match symbol-keyed entries, so wire-shape
2060
+ # hashes (which arrive with string keys from JSON) must be normalised
2061
+ # before the inner case/in can deconstruct them.
2059
2062
  case value
2060
- when Parse::GeoJSON::Geometry then value.to_geojson
2061
- when Parse::Polygon then value.to_geojson
2062
- when Parse::GeoPoint then value.to_geojson
2063
- when Hash
2064
- h = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
2065
- type = h[:type] || h["type"]
2066
- coords = h[:coordinates] || h["coordinates"]
2067
- unless type.is_a?(String) && ALLOWED_GEOJSON_TYPES.include?(type) && coords.is_a?(Array)
2063
+ in Parse::GeoJSON::Geometry | Parse::Polygon | Parse::GeoPoint
2064
+ value.to_geojson
2065
+ in Hash => h
2066
+ normalised = h.respond_to?(:symbolize_keys) ? h.symbolize_keys : h
2067
+ case normalised
2068
+ in { type: String => type, coordinates: Array => coords } if ALLOWED_GEOJSON_TYPES.include?(type)
2069
+ { "type" => type, "coordinates" => coords }
2070
+ else
2068
2071
  raise ArgumentError, "[Parse::Query] `geo_intersects` Hash must be a GeoJSON geometry " \
2069
2072
  "with one of the RFC 7946 types " \
2070
2073
  "(#{ALLOWED_GEOJSON_TYPES.join(", ")}) and an Array of coordinates."
2071
2074
  end
2072
- { "type" => type, "coordinates" => coords }
2073
2075
  else
2074
2076
  raise ArgumentError, "[Parse::Query] `geo_intersects` expects a Parse::GeoPoint, " \
2075
2077
  "Parse::Polygon, Parse::GeoJSON::Geometry, or GeoJSON Hash."
@@ -2874,15 +2876,15 @@ module Parse
2874
2876
  # Uses MongoDB's $lookup to join collections and $expr with $ne to compare fields.
2875
2877
  #
2876
2878
  # Usage:
2877
- # Asset.where(:project.does_not_equal_linked_pointer => { through: :capture, field: :project })
2879
+ # Document.where(:project.does_not_equal_linked_pointer => { through: :post, field: :project })
2878
2880
  #
2879
2881
  # This generates a MongoDB aggregation pipeline that:
2880
2882
  # 1. Uses $lookup to join the linked collection
2881
2883
  # 2. Uses $match with $expr and $ne to find records where fields do NOT match
2882
2884
  #
2883
- # @example Find assets where the project does not equal the capture's project
2884
- # Asset.where(:project.does_not_equal_linked_pointer => {
2885
- # through: :capture,
2885
+ # @example Find assets where the project does not equal the post's project
2886
+ # Document.where(:project.does_not_equal_linked_pointer => {
2887
+ # through: :post,
2886
2888
  # field: :project
2887
2889
  # })
2888
2890
  class DoesNotEqualLinkedPointerConstraint < Constraint
@@ -9,7 +9,7 @@ module Parse
9
9
  # special methods to the Symbol class. The developer can then pass one
10
10
  # or an array of fields (as symbols) and call the particular ordering
11
11
  # polarity (ex. _:name.asc_ would create a Parse::Order where we want
12
- # things to be sortd by the name field in ascending order)
12
+ # things to be sorted by the name field in ascending order)
13
13
  # For more information about the query design pattern from DataMapper
14
14
  # that inspired this, see http://datamapper.org/docs/find.html'
15
15
  # @example