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.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- 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-
|
|
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
|
|
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
|
|
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" => "
|
|
1371
|
+
"as" => "subscriptions",
|
|
1371
1372
|
} },
|
|
1372
1373
|
{ "$project" => {
|
|
1373
1374
|
"_id" => 0,
|
|
1374
|
-
"user_id_candidates" => "$
|
|
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
|
|
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 +"
|
|
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
|
|
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
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
type
|
|
2066
|
-
|
|
2067
|
-
|
|
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
|
-
#
|
|
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
|
|
2884
|
-
#
|
|
2885
|
-
# through: :
|
|
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
|
data/lib/parse/query/ordering.rb
CHANGED
|
@@ -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
|
|
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
|