parse-stack-next 5.7.6 → 5.8.0
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/CHANGELOG.md +830 -0
- data/README.md +14 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +317 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +93 -1
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +21 -3
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +72 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
|
@@ -561,9 +561,9 @@ module Parse
|
|
|
561
561
|
|
|
562
562
|
# @!visibility private
|
|
563
563
|
# Returns `true` when `@object`/`@original` contain a className that
|
|
564
|
-
# disagrees with the trigger's expected class
|
|
565
|
-
#
|
|
566
|
-
# incorrectly.
|
|
564
|
+
# disagrees with the trigger's expected class (the class from the
|
|
565
|
+
# webhook URL path). The Rack app refuses such a request before any
|
|
566
|
+
# handler runs, since the payload was forged or routed incorrectly.
|
|
567
567
|
def payload_class_mismatch?
|
|
568
568
|
expected = parse_class
|
|
569
569
|
return false if expected.nil?
|
|
@@ -573,6 +573,44 @@ module Parse
|
|
|
573
573
|
end
|
|
574
574
|
end
|
|
575
575
|
|
|
576
|
+
# @!visibility private
|
|
577
|
+
# The memoized {#parse_object} if one was built during this request
|
|
578
|
+
# (by a handler or the field-guard step), else nil. Never builds one.
|
|
579
|
+
# @return [Parse::Object, nil]
|
|
580
|
+
def memoized_parse_object
|
|
581
|
+
defined?(@parse_object) ? @parse_object : nil
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# @!visibility private
|
|
585
|
+
# A new, unmemoized build of the trigger object, as the client's write
|
|
586
|
+
# describes it (no handler edits, no field-guard reverts). The beforeSave
|
|
587
|
+
# reply diffs the handler's object against this.
|
|
588
|
+
# @return [Parse::Object, nil]
|
|
589
|
+
def unmemoized_parse_object
|
|
590
|
+
return nil unless object?
|
|
591
|
+
build_parse_object
|
|
592
|
+
rescue StandardError
|
|
593
|
+
# Comparison baseline only: an unbuildable payload (e.g. a class with
|
|
594
|
+
# no registered model) leaves every dirty field counted as changed.
|
|
595
|
+
nil
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# @!visibility private
|
|
599
|
+
# The `object` hash exactly as Parse Server sent it, before credential
|
|
600
|
+
# and vector scrubbing. Used only to rebuild the client's write for a
|
|
601
|
+
# beforeSave reply; never logged or exposed through {#as_json}.
|
|
602
|
+
# @return [Hash, nil]
|
|
603
|
+
def raw_object
|
|
604
|
+
@raw.is_a?(Hash) ? @raw[:object] : nil
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# @!visibility private
|
|
608
|
+
# The `original` hash exactly as Parse Server sent it (see {#raw_object}).
|
|
609
|
+
# @return [Hash, nil]
|
|
610
|
+
def raw_original
|
|
611
|
+
@raw.is_a?(Hash) ? @raw[:original] : nil
|
|
612
|
+
end
|
|
613
|
+
|
|
576
614
|
# Force a fresh build, discarding any memoized parse_object. Used by the
|
|
577
615
|
# webhook framework after mutating @object / @update so a subsequent
|
|
578
616
|
# parse_object call picks up the modified payload state.
|
|
@@ -678,11 +716,20 @@ module Parse
|
|
|
678
716
|
# a specific message. When used inside of a registered cloud code webhook
|
|
679
717
|
# function or trigger, will halt processing and return the proper error response
|
|
680
718
|
# code back to the Parse server.
|
|
719
|
+
# @example
|
|
720
|
+
# error!("title is required")
|
|
721
|
+
# error!("duplicate slug", code: 137) # see note on code
|
|
681
722
|
# @param msg [String] the error message to send back.
|
|
723
|
+
# @param code [Integer, nil] an optional Parse error code, written to the
|
|
724
|
+
# error body as `"code"` and available as
|
|
725
|
+
# {Parse::Webhooks::ResponseError#code}. Parse Server's HTTP webhook
|
|
726
|
+
# adapter (as of 9.10) still reports webhook errors to the client as
|
|
727
|
+
# code 141; the code reaches in-process callers such as
|
|
728
|
+
# {Parse::Webhooks.run_function}.
|
|
682
729
|
# @raise Parse::Webhooks::ResponseError
|
|
683
730
|
# @return [Parse::Webhooks::ResponseError] the raised exception
|
|
684
|
-
def error!(msg = "")
|
|
685
|
-
raise Parse::Webhooks::ResponseError,
|
|
731
|
+
def error!(msg = "", code: nil)
|
|
732
|
+
raise Parse::Webhooks::ResponseError.new(msg, code: code)
|
|
686
733
|
end
|
|
687
734
|
|
|
688
735
|
# Register a block to run **after** this webhook's response has been sent
|
|
@@ -729,10 +776,50 @@ module Parse
|
|
|
729
776
|
@deferred_callbacks ||= []
|
|
730
777
|
end
|
|
731
778
|
|
|
779
|
+
# The query of a beforeFind (or LiveQuery beforeSubscribe) trigger as a
|
|
780
|
+
# {Parse::Query}.
|
|
781
|
+
#
|
|
782
|
+
# Parse Server sends the query in its REST JSON form: the constraints sit
|
|
783
|
+
# under `where`, next to `limit`, `skip`, `order` (a comma-separated
|
|
784
|
+
# string, `-` for descending), `keys` and `include`. Only the `where`
|
|
785
|
+
# entries become constraints, and they are added verbatim, so a field
|
|
786
|
+
# that happens to be named like a query option (`limit`, `order`,
|
|
787
|
+
# `key`) is still a field constraint. A top-level `$or` is kept; other
|
|
788
|
+
# top-level operators are left out of the returned query (the raw form
|
|
789
|
+
# is always available as {#query}).
|
|
732
790
|
# @return [Parse::Query] the Parse query for a beforeFind trigger.
|
|
733
791
|
def parse_query
|
|
734
792
|
return nil unless parse_class.present? && @query.is_a?(Hash)
|
|
735
|
-
|
|
793
|
+
spec = @query.with_indifferent_access
|
|
794
|
+
q = Parse::Query.new(parse_class)
|
|
795
|
+
where = spec[:where]
|
|
796
|
+
if where.is_a?(Hash)
|
|
797
|
+
constraints = where.filter_map do |field, value|
|
|
798
|
+
field = field.to_s
|
|
799
|
+
if field == "$or"
|
|
800
|
+
Parse::Constraint::CompoundQueryConstraint.new(:or, Array.wrap(value))
|
|
801
|
+
elsif field.start_with?("$")
|
|
802
|
+
# Other top-level operators ($and, $nor, $relatedTo) have no
|
|
803
|
+
# field-constraint form here; they stay visible in #query.
|
|
804
|
+
nil
|
|
805
|
+
else
|
|
806
|
+
Parse::Constraint.create(field, value)
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
q.add_constraints(constraints)
|
|
810
|
+
end
|
|
811
|
+
q.limit(spec[:limit].to_i) if spec[:limit].is_a?(Numeric) && spec[:limit].to_i >= 0
|
|
812
|
+
q.skip(spec[:skip].to_i) if spec[:skip].is_a?(Numeric) && spec[:skip].to_i > 0
|
|
813
|
+
split = ->(v) { v.is_a?(Array) ? v.map(&:to_s) : v.to_s.split(",").map(&:strip).reject(&:empty?) }
|
|
814
|
+
order = split.call(spec[:order]).map do |f|
|
|
815
|
+
f.start_with?("-") ? Parse::Order.new(f[1..].to_sym, :desc) : Parse::Order.new(f.to_sym, :asc)
|
|
816
|
+
end
|
|
817
|
+
q.order(order) if order.any?
|
|
818
|
+
keys = split.call(spec[:keys])
|
|
819
|
+
q.keys(keys) if keys.any?
|
|
820
|
+
includes = split.call(spec[:include])
|
|
821
|
+
q.includes(includes) if includes.any?
|
|
822
|
+
q
|
|
736
823
|
end
|
|
737
824
|
|
|
738
825
|
# Returns true if this webhook was triggered by a Ruby Parse Stack request.
|
|
@@ -19,13 +19,19 @@ module Parse
|
|
|
19
19
|
#
|
|
20
20
|
# This module adds two layers on top of the existing static-key check:
|
|
21
21
|
#
|
|
22
|
-
# 1. **
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
22
|
+
# 1. **Nonce-keyed dedup.** When a delivery carries a per-delivery
|
|
23
|
+
# identifier (an `X-Parse-Request-Id` or `X-Parse-Webhook-Nonce`
|
|
24
|
+
# header), a bounded LRU records a SHA-256 of that identifier joined
|
|
25
|
+
# with the request body. A duplicate seen within
|
|
26
|
+
# `replay_window_seconds` is rejected with
|
|
27
|
+
# `"Webhook replay detected."`.
|
|
28
|
+
#
|
|
29
|
+
# Parse Server sends neither header on its webhook deliveries, so for
|
|
30
|
+
# a stock deployment this layer is inactive. It deliberately does NOT
|
|
31
|
+
# fall back to the body alone: two legitimate calls with identical
|
|
32
|
+
# bodies (the same function called twice with the same params, the
|
|
33
|
+
# same find run twice) are indistinguishable from a replay by body,
|
|
34
|
+
# and rejecting them breaks correct traffic.
|
|
29
35
|
#
|
|
30
36
|
# 2. **Opt-in HMAC freshness verification.** When a `signing_secret` is
|
|
31
37
|
# configured (programmatically or via
|
|
@@ -37,20 +43,22 @@ module Parse
|
|
|
37
43
|
# bytes `"#{timestamp}.#{body}"` keyed with the signing secret.
|
|
38
44
|
#
|
|
39
45
|
# Requests outside `signing_max_skew_seconds` (default 300) or with
|
|
40
|
-
# an invalid signature are rejected.
|
|
41
|
-
#
|
|
42
|
-
#
|
|
46
|
+
# an invalid signature are rejected. This bounds a replay to the skew
|
|
47
|
+
# window; adding an `X-Parse-Webhook-Nonce` header as well closes that
|
|
48
|
+
# window through layer 1.
|
|
43
49
|
#
|
|
44
|
-
# Operators wanting layer
|
|
45
|
-
#
|
|
46
|
-
# this is typically done with a thin Cloud Code
|
|
47
|
-
#
|
|
50
|
+
# Operators wanting either layer must arrange for these headers to be
|
|
51
|
+
# added. Parse Server does not natively sign webhook deliveries or tag
|
|
52
|
+
# them with a nonce, so this is typically done with a thin Cloud Code
|
|
53
|
+
# wrapper or an egress proxy.
|
|
48
54
|
module ReplayProtection
|
|
49
55
|
# @!visibility private
|
|
50
56
|
HEADER_TIMESTAMP = "HTTP_X_PARSE_WEBHOOK_TIMESTAMP"
|
|
51
57
|
# @!visibility private
|
|
52
58
|
HEADER_SIGNATURE = "HTTP_X_PARSE_WEBHOOK_SIGNATURE"
|
|
53
59
|
# @!visibility private
|
|
60
|
+
HEADER_NONCE = "HTTP_X_PARSE_WEBHOOK_NONCE"
|
|
61
|
+
# @!visibility private
|
|
54
62
|
DEFAULT_REPLAY_WINDOW = 300
|
|
55
63
|
# @!visibility private
|
|
56
64
|
DEFAULT_REPLAY_CACHE_SIZE = 10_000
|
|
@@ -76,7 +84,7 @@ module Parse
|
|
|
76
84
|
@signing_max_skew_seconds || DEFAULT_MAX_SKEW
|
|
77
85
|
end
|
|
78
86
|
|
|
79
|
-
# How long a `(
|
|
87
|
+
# How long a `(nonce, body)` digest stays in the dedup cache.
|
|
80
88
|
# Duplicates seen within this window are rejected.
|
|
81
89
|
def replay_window_seconds
|
|
82
90
|
@replay_window_seconds || DEFAULT_REPLAY_WINDOW
|
|
@@ -113,9 +121,11 @@ module Parse
|
|
|
113
121
|
# Returns nil when the request passes both replay and signature
|
|
114
122
|
# checks; otherwise returns a short error string suitable for the
|
|
115
123
|
# webhook error response. The headers come from `env` so this
|
|
116
|
-
# works with any Rack request.
|
|
124
|
+
# works with any Rack request. Replay dedup applies only when
|
|
125
|
+
# `request_id` or an `X-Parse-Webhook-Nonce` header is present.
|
|
117
126
|
def verify!(env, body_str, request_id)
|
|
118
127
|
secret = signing_secret
|
|
128
|
+
signed_key = nil
|
|
119
129
|
if secret && !secret.empty?
|
|
120
130
|
ts_header = env[HEADER_TIMESTAMP].to_s
|
|
121
131
|
sig_header = env[HEADER_SIGNATURE].to_s
|
|
@@ -124,13 +134,41 @@ module Parse
|
|
|
124
134
|
ts = ts_header.to_i
|
|
125
135
|
skew = (Time.now.to_i - ts).abs
|
|
126
136
|
return "Stale webhook timestamp." if skew > signing_max_skew_seconds
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
137
|
+
# A sender that signs a per-delivery nonce (`ts.nonce.body`) gets a
|
|
138
|
+
# distinct signature for every delivery, so identical bodies sent
|
|
139
|
+
# in the same second are not mistaken for replays. The original
|
|
140
|
+
# `ts.body` form is still accepted.
|
|
141
|
+
delivery_nonce = env[HEADER_NONCE].to_s.strip
|
|
142
|
+
candidates = ["#{ts}.#{body_str}"]
|
|
143
|
+
candidates.unshift("#{ts}.#{delivery_nonce}.#{body_str}") unless delivery_nonce.empty?
|
|
144
|
+
matched = candidates.any? do |material|
|
|
145
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, material)
|
|
146
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, sig_header)
|
|
130
147
|
end
|
|
148
|
+
return "Invalid webhook signature." unless matched
|
|
149
|
+
# A signed delivery is deduplicated on its signature, which covers
|
|
150
|
+
# the timestamp and body and cannot be changed without the secret.
|
|
151
|
+
# Keying it on the unsigned nonce would let a captured request be
|
|
152
|
+
# replayed within the timestamp window by altering or dropping
|
|
153
|
+
# that header.
|
|
154
|
+
signed_key = "sig\x1f#{sig_header}"
|
|
131
155
|
end
|
|
132
156
|
|
|
133
|
-
|
|
157
|
+
if signed_key
|
|
158
|
+
window = [replay_window_seconds, signing_max_skew_seconds * 2].max
|
|
159
|
+
digest = Digest::SHA256.hexdigest(signed_key)
|
|
160
|
+
return "Webhook replay detected." if cache.seen?(digest, window)
|
|
161
|
+
cache.record(digest, replay_cache_size)
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Dedup only when the delivery carries a per-delivery identifier.
|
|
166
|
+
# Keying on the body alone rejects legitimate identical requests.
|
|
167
|
+
nonce = request_id.to_s.strip
|
|
168
|
+
nonce = env[HEADER_NONCE].to_s.strip if nonce.empty?
|
|
169
|
+
return nil if nonce.empty?
|
|
170
|
+
|
|
171
|
+
digest = Digest::SHA256.hexdigest("#{nonce}\x1f#{body_str}")
|
|
134
172
|
if cache.seen?(digest, replay_window_seconds)
|
|
135
173
|
return "Webhook replay detected."
|
|
136
174
|
end
|