parse-stack-next 5.2.1 → 5.4.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/.bundle/config +1 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +616 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +296 -3
- data/Rakefile +243 -41
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +38 -0
- data/docs/mcp_guide.md +119 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +174 -9
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +200 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/pluralized_aliases.rb +30 -0
- data/lib/parse/model/core/properties.rb +27 -0
- data/lib/parse/model/core/querying.rb +73 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +173 -1
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +399 -11
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- metadata +16 -1
|
@@ -25,7 +25,9 @@ module Parse
|
|
|
25
25
|
original: nil, update: nil,
|
|
26
26
|
query: nil, log: nil,
|
|
27
27
|
objects: nil,
|
|
28
|
-
triggerName: nil
|
|
28
|
+
triggerName: nil,
|
|
29
|
+
event: nil, clients: nil, subscriptions: nil,
|
|
30
|
+
context: nil }.freeze
|
|
29
31
|
include ::ActiveModel::Serializers::JSON
|
|
30
32
|
# @!attribute [rw] master
|
|
31
33
|
# @return [Boolean] whether the master key was used for this request.
|
|
@@ -64,6 +66,43 @@ module Parse
|
|
|
64
66
|
attr_accessor :master, :user, :installation_id, :params, :function_name, :object, :trigger_name
|
|
65
67
|
attr_accessor :query, :log, :objects
|
|
66
68
|
attr_accessor :original, :update, :raw
|
|
69
|
+
# @!attribute [rw] event
|
|
70
|
+
# The LiveQuery event type for an +afterEvent+ trigger -- one of
|
|
71
|
+
# +"create"+, +"enter"+, +"update"+, +"leave"+, or +"delete"+ -- or
|
|
72
|
+
# +"connect"+ for a +beforeConnect+ trigger. +nil+ for every non-
|
|
73
|
+
# LiveQuery trigger. See {#after_event?} / {#before_connect?}.
|
|
74
|
+
# @return [String, nil]
|
|
75
|
+
# @!attribute [rw] clients
|
|
76
|
+
# Connection-global metadata sent on the LiveQuery +beforeConnect+ /
|
|
77
|
+
# +afterEvent+ triggers: the number of currently-connected LiveQuery
|
|
78
|
+
# clients. +nil+ for non-LiveQuery triggers.
|
|
79
|
+
# @return [Integer, nil]
|
|
80
|
+
# @!attribute [rw] subscriptions
|
|
81
|
+
# Connection-global metadata sent on the LiveQuery +beforeConnect+ /
|
|
82
|
+
# +afterEvent+ triggers: the number of active subscriptions. +nil+ for
|
|
83
|
+
# non-LiveQuery triggers.
|
|
84
|
+
# @return [Integer, nil]
|
|
85
|
+
attr_accessor :event, :clients, :subscriptions
|
|
86
|
+
# @!attribute [rw] context
|
|
87
|
+
# The caller-supplied context object threaded from the originating REST
|
|
88
|
+
# write or cloud-function call via the +X-Parse-Cloud-Context+ header.
|
|
89
|
+
# Parse Server includes this as a top-level +context+ key in trigger
|
|
90
|
+
# payloads (beforeSave/afterSave/etc.). Returns a Hash when present, or
|
|
91
|
+
# +nil+ when the originating request carried no context.
|
|
92
|
+
# @return [Hash, nil]
|
|
93
|
+
attr_accessor :context
|
|
94
|
+
# @!attribute [r] session_token
|
|
95
|
+
# The caller's live Parse session token, captured from the incoming
|
|
96
|
+
# webhook payload (`user.sessionToken`) before credentials are scrubbed
|
|
97
|
+
# from {#user} / {#object} / {#original} / {#update}. Present only when
|
|
98
|
+
# the originating request was made by a logged-in user -- a master-key
|
|
99
|
+
# request carries no user and no token, so this is +nil+. It is
|
|
100
|
+
# intentionally NOT one of {ATTRIBUTES}, so it never appears in
|
|
101
|
+
# {#as_json} or in the redacted request log. Reach for it (or the
|
|
102
|
+
# higher-level {#user_client} / {#user_agent}) only when a handler
|
|
103
|
+
# deliberately wants to act on the server as the calling user.
|
|
104
|
+
# @return [String, nil]
|
|
105
|
+
attr_reader :session_token
|
|
67
106
|
# @!visibility private
|
|
68
107
|
attr_accessor :webhook_class
|
|
69
108
|
alias_method :installationId, :installation_id
|
|
@@ -73,11 +112,44 @@ module Parse
|
|
|
73
112
|
# You would normally never create a {Parse::Webhooks::Payload} object since it is automatically
|
|
74
113
|
# provided to you when using Parse::Webhooks.
|
|
75
114
|
# @see Parse::Webhooks
|
|
76
|
-
|
|
115
|
+
# @param hash [String, Hash] the raw webhook body (JSON string or Hash).
|
|
116
|
+
# @param webhook_class [String, nil] the Parse class name derived from the
|
|
117
|
+
# webhook URL path (`<endpoint>/<triggerName>/<className>`). This is the
|
|
118
|
+
# ONLY authoritative source of the class for beforeFind/afterFind
|
|
119
|
+
# triggers — Parse Server omits `className` from the find payload body
|
|
120
|
+
# entirely (the matched `objects` carry no `className` and there is no
|
|
121
|
+
# top-level one). Threading it here lets `parse_class` resolve (so find
|
|
122
|
+
# triggers route) and lets `:vector` columns be stripped from afterFind
|
|
123
|
+
# `objects`. For save/delete triggers the path className equals the
|
|
124
|
+
# body's, so it is consistent; for functions it is nil.
|
|
125
|
+
def initialize(hash = {}, webhook_class = nil)
|
|
77
126
|
hash = JSON.parse(hash, max_nesting: 20) if hash.is_a?(String)
|
|
78
127
|
hash = Hash[hash.map { |k, v| [k.to_s.underscore.to_sym, v] }]
|
|
79
128
|
@raw = hash
|
|
129
|
+
# Set BEFORE the vector scrub below so the route-derived class is
|
|
130
|
+
# available to strip :vector columns from afterFind objects (whose
|
|
131
|
+
# body carries no className of its own).
|
|
132
|
+
@webhook_class = webhook_class.to_s if webhook_class && !webhook_class.to_s.empty?
|
|
80
133
|
@master = hash[:master]
|
|
134
|
+
# Capture the caller's session token from the *unscrubbed* user hash
|
|
135
|
+
# before scrub_credentials strips it below. Parse Server includes
|
|
136
|
+
# `user.sessionToken` on every trigger fired by a logged-in caller
|
|
137
|
+
# (it is absent for master-key-originated requests). Pulling it aside
|
|
138
|
+
# here -- rather than leaving it in @user -- keeps it out of any object
|
|
139
|
+
# a handler might persist and out of #as_json / the request log, while
|
|
140
|
+
# still letting a handler opt in to acting as the calling user via
|
|
141
|
+
# #session_token / #user_client / #user_agent.
|
|
142
|
+
@session_token = self.class.extract_session_token(hash[:user])
|
|
143
|
+
# LiveQuery beforeConnect/beforeSubscribe carry the caller's session
|
|
144
|
+
# token at the TOP LEVEL (not nested under `user`), because no user is
|
|
145
|
+
# resolved yet when the trigger fires. Capture it here -- with the same
|
|
146
|
+
# "set it aside, keep it out of as_json / the log" treatment as the
|
|
147
|
+
# nested form -- so #user_client / #user_agent can act as the caller.
|
|
148
|
+
# It is intentionally NOT one of ATTRIBUTES.
|
|
149
|
+
if @session_token.nil?
|
|
150
|
+
top_token = hash[:session_token].to_s.strip
|
|
151
|
+
@session_token = top_token unless top_token.empty?
|
|
152
|
+
end
|
|
81
153
|
# Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
|
|
82
154
|
# Parse Server and, when a webhook key is configured (the default; see
|
|
83
155
|
# Parse::Webhooks.allow_unauthenticated for the opt-out used in tests /
|
|
@@ -85,7 +157,8 @@ module Parse
|
|
|
85
157
|
# server-authoritative state. A handler is meant to receive the full
|
|
86
158
|
# object -- createdAt/updatedAt, ACL, internal fields and all. The only
|
|
87
159
|
# thing stripped here is genuine credential material a handler never
|
|
88
|
-
# legitimately needs to read (live session tokens
|
|
160
|
+
# legitimately needs to read inline (live session tokens -- captured
|
|
161
|
+
# above for opt-in user-scoped clients first -- and offline-crackable
|
|
89
162
|
# password hashes); see WEBHOOK_TRIGGER_CREDENTIAL_KEYS. Protection
|
|
90
163
|
# against *persisting* forged privileged fields lives on the write path
|
|
91
164
|
# (changes_payload emits only declared, dirty-tracked properties), not on
|
|
@@ -103,14 +176,42 @@ module Parse
|
|
|
103
176
|
@params = hash[:params]
|
|
104
177
|
@params = @params.with_indifferent_access if @params.is_a?(Hash)
|
|
105
178
|
@function_name = hash[:function_name]
|
|
106
|
-
@object = self.class.scrub_credentials(hash[:object])
|
|
107
179
|
@trigger_name = hash[:trigger_name]
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
#
|
|
180
|
+
# Resolve the model class once so :vector columns can be stripped from
|
|
181
|
+
# every object-shaped payload (see scrub_vector_columns). Credentials
|
|
182
|
+
# are scrubbed first, then vectors. The route-derived @webhook_class is
|
|
183
|
+
# authoritative and preferred — it is the only class source for
|
|
184
|
+
# afterFind (whose body carries no className anywhere); for save/delete
|
|
185
|
+
# it equals the body's className. Falls back to the object/original
|
|
186
|
+
# className for older callers that don't supply a route class.
|
|
187
|
+
vec_klass = self.class.resolve_klass_by_name(@webhook_class) ||
|
|
188
|
+
self.class.resolve_vector_klass(hash[:object], hash[:original])
|
|
189
|
+
@object = self.class.scrub_vector_columns(self.class.scrub_credentials(hash[:object]), vec_klass)
|
|
190
|
+
@original = self.class.scrub_vector_columns(self.class.scrub_credentials(hash[:original]), vec_klass)
|
|
191
|
+
@update = self.class.scrub_vector_columns(self.class.scrub_credentials(hash[:update]), vec_klass) || {}
|
|
192
|
+
# Added for beforeFind and afterFind triggers. afterFind objects are all
|
|
193
|
+
# of one class but carry no className of their own, so the route-derived
|
|
194
|
+
# vec_klass is the only way to strip their :vector columns.
|
|
111
195
|
@query = hash[:query]
|
|
112
|
-
|
|
196
|
+
# LiveQuery connection metadata. `event` is the afterEvent event type
|
|
197
|
+
# (create/enter/update/leave/delete) or "connect" for beforeConnect;
|
|
198
|
+
# `clients`/`subscriptions` are connection-global counts. All nil for
|
|
199
|
+
# the object / auth triggers. These are plain scalars (no credential
|
|
200
|
+
# material), so they pass through unscrubbed.
|
|
201
|
+
@event = hash[:event]
|
|
202
|
+
@clients = hash[:clients]
|
|
203
|
+
@subscriptions = hash[:subscriptions]
|
|
204
|
+
@objects = Array(hash[:objects]).map do |o|
|
|
205
|
+
self.class.scrub_vector_columns(self.class.scrub_credentials(o), vec_klass)
|
|
206
|
+
end
|
|
113
207
|
@log = hash[:log]
|
|
208
|
+
# Caller-supplied context object threaded via X-Parse-Cloud-Context.
|
|
209
|
+
# This is caller metadata (not a credential), so it passes through
|
|
210
|
+
# without scrubbing — mirroring the treatment of @query and @log.
|
|
211
|
+
@context = hash[:context]
|
|
212
|
+
# Blocks registered by a handler via #after_response / #defer, to run
|
|
213
|
+
# after the webhook response has been sent (drained by the Rack app).
|
|
214
|
+
@deferred_callbacks = []
|
|
114
215
|
end
|
|
115
216
|
|
|
116
217
|
# @!visibility private
|
|
@@ -144,11 +245,95 @@ module Parse
|
|
|
144
245
|
end
|
|
145
246
|
end
|
|
146
247
|
|
|
248
|
+
# @!visibility private
|
|
249
|
+
# Resolve the Parse::Object subclass for a webhook payload from the
|
|
250
|
+
# `className` of the first object-shaped hash given. Returns nil when
|
|
251
|
+
# no class name is present or no matching model is registered (the
|
|
252
|
+
# caller then skips vector stripping — fail-open is acceptable here:
|
|
253
|
+
# an unregistered class has no declared `:vector` columns to strip).
|
|
254
|
+
def self.resolve_vector_klass(*candidates)
|
|
255
|
+
candidates.each do |obj|
|
|
256
|
+
next unless obj.is_a?(Hash)
|
|
257
|
+
name = obj["className"] || obj[:className]
|
|
258
|
+
next if name.nil? || name.to_s.empty?
|
|
259
|
+
klass = resolve_klass_by_name(name)
|
|
260
|
+
return klass if klass
|
|
261
|
+
end
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# @!visibility private
|
|
266
|
+
# Resolve a registered Parse::Object subclass from a bare class-name
|
|
267
|
+
# string (e.g. the route-derived @webhook_class). Returns nil for a blank
|
|
268
|
+
# name or an unregistered class (the caller then skips vector stripping —
|
|
269
|
+
# fail-open, as an unregistered class has no declared :vector columns).
|
|
270
|
+
def self.resolve_klass_by_name(name)
|
|
271
|
+
return nil if name.nil? || name.to_s.empty?
|
|
272
|
+
klass = (Parse::Object.find_class(name.to_s) rescue nil)
|
|
273
|
+
klass.respond_to?(:fields) ? klass : nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @!visibility private
|
|
277
|
+
# Returns a copy of +obj+ with the model's declared `:vector`
|
|
278
|
+
# columns removed. Embeddings are large dense float arrays that leak
|
|
279
|
+
# ML signal; a webhook handler has no reason to receive them, and
|
|
280
|
+
# leaving them in bloats logs and any object a handler re-persists.
|
|
281
|
+
# Mirrors the `as_json` default (vectors omitted) — a class that opts
|
|
282
|
+
# into `vector_visibility :public` keeps its vectors here too.
|
|
283
|
+
#
|
|
284
|
+
# `klass` may be passed explicitly (so changed-only payloads like
|
|
285
|
+
# `update`, which carry no `className`, are still scrubbed using the
|
|
286
|
+
# class resolved from the sibling `object`/`original` hash); when nil
|
|
287
|
+
# it is resolved from the hash's own `className`.
|
|
288
|
+
# Pass-through for non-Hash input (and nil).
|
|
289
|
+
def self.scrub_vector_columns(obj, klass = nil)
|
|
290
|
+
return obj unless obj.is_a?(Hash)
|
|
291
|
+
klass ||= resolve_vector_klass(obj)
|
|
292
|
+
return obj if klass.nil?
|
|
293
|
+
if klass.respond_to?(:vectors_public_by_default?) && klass.vectors_public_by_default?
|
|
294
|
+
return obj
|
|
295
|
+
end
|
|
296
|
+
vector_fields = klass.fields(:vector).keys.map(&:to_s)
|
|
297
|
+
return obj if vector_fields.empty?
|
|
298
|
+
field_map = klass.respond_to?(:field_map) ? klass.field_map : {}
|
|
299
|
+
wire = vector_fields.map { |f| (field_map[f.to_sym] || f).to_s }
|
|
300
|
+
denied = (vector_fields + wire)
|
|
301
|
+
obj.reject { |k, _| denied.include?(k.to_s) }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# @!visibility private
|
|
305
|
+
# Pulls the caller's session token out of the (unscrubbed) +user+ hash.
|
|
306
|
+
# Parse Server sends it as the camelCase string key +sessionToken+; this
|
|
307
|
+
# tolerates a symbol key and the snake_case form too, mirroring the
|
|
308
|
+
# leniency in +scrub_credentials+. Returns +nil+ for a blank token or a
|
|
309
|
+
# non-Hash / absent user (a master-key request has no user).
|
|
310
|
+
def self.extract_session_token(user_hash)
|
|
311
|
+
return nil unless user_hash.is_a?(Hash)
|
|
312
|
+
token = user_hash["sessionToken"] || user_hash[:sessionToken] ||
|
|
313
|
+
user_hash["session_token"] || user_hash[:session_token]
|
|
314
|
+
token = token.to_s.strip
|
|
315
|
+
token.empty? ? nil : token
|
|
316
|
+
end
|
|
317
|
+
|
|
147
318
|
# @return [ATTRIBUTES]
|
|
148
319
|
def attributes
|
|
149
320
|
ATTRIBUTES
|
|
150
321
|
end
|
|
151
322
|
|
|
323
|
+
# Redacted inspection. The default Ruby `#inspect` would dump every ivar,
|
|
324
|
+
# including the captured `@session_token` and the *pre-scrub* `@raw` hash
|
|
325
|
+
# (which still holds the caller's sessionToken and any password hashes).
|
|
326
|
+
# That is exactly the surface an error reporter or a stray `p payload`
|
|
327
|
+
# hits, so show only non-sensitive routing fields and a boolean for the
|
|
328
|
+
# token's presence. Use #as_json / the individual accessors for the
|
|
329
|
+
# (already credential-scrubbed) object data.
|
|
330
|
+
def inspect
|
|
331
|
+
"#<#{self.class.name} trigger=#{@trigger_name.inspect} " \
|
|
332
|
+
"function=#{@function_name.inspect} class=#{parse_class.inspect} " \
|
|
333
|
+
"id=#{parse_id.inspect} master=#{@master ? true : false} " \
|
|
334
|
+
"session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
|
|
335
|
+
end
|
|
336
|
+
|
|
152
337
|
# Method to print to standard that utilizes the an internal id to make it easier
|
|
153
338
|
# to trace incoming requests.
|
|
154
339
|
def wlog(s)
|
|
@@ -170,6 +355,51 @@ module Parse
|
|
|
170
355
|
@master.present?
|
|
171
356
|
end
|
|
172
357
|
|
|
358
|
+
# true if this payload carried a caller session token -- i.e. the
|
|
359
|
+
# originating request was made by a logged-in user rather than the
|
|
360
|
+
# master key, so {#user_client} / {#user_agent} can act as that user.
|
|
361
|
+
# @return [Boolean]
|
|
362
|
+
def session_token?
|
|
363
|
+
!@session_token.nil?
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# An opt-in, user-scoped {Parse::Client} for acting on the server as the
|
|
367
|
+
# webhook's calling user. It mirrors the default client's connection
|
|
368
|
+
# settings (+server_url+, +application_id+, +api_key+) but carries NO
|
|
369
|
+
# master key and BINDS the caller's {#session_token}, so every request it
|
|
370
|
+
# makes -- with no further ceremony -- is authorized by Parse Server as
|
|
371
|
+
# that user: ACL, CLP and +protectedFields+ are all enforced. (A
|
|
372
|
+
# `Parse.with_session` block still overrides the bound token if you need
|
|
373
|
+
# to act as someone else within a call.) Memoized per payload, since each
|
|
374
|
+
# webhook delivery carries a distinct token.
|
|
375
|
+
# @return [Parse::Client, nil] +nil+ when the payload carried no token.
|
|
376
|
+
def user_client
|
|
377
|
+
return nil if @session_token.nil?
|
|
378
|
+
@user_client ||= Parse::Client.client.become(@session_token)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# An opt-in, non-master {Parse::Agent} scoped to the webhook caller's
|
|
382
|
+
# session token. Because its client has no master key and it is built
|
|
383
|
+
# with a non-empty +session_token:+, the agent runs in CLIENT MODE:
|
|
384
|
+
# every tool/query routes through a path Parse Server (or the SDK's own
|
|
385
|
+
# ACL/CLP enforcement layer) authorizes as the calling user, with no
|
|
386
|
+
# master-key fallback to silently bypass row-level security. This is the
|
|
387
|
+
# handle to use when a handler should read or act strictly within the
|
|
388
|
+
# caller's permissions. Additional agent options (e.g.
|
|
389
|
+
# +permissions: :readwrite+) may be passed through.
|
|
390
|
+
# @param opts [Hash] extra keyword args forwarded to {Parse::Agent#initialize}.
|
|
391
|
+
# @return [Parse::Agent, nil] +nil+ when the payload carried no token.
|
|
392
|
+
def user_agent(**opts)
|
|
393
|
+
return nil if @session_token.nil?
|
|
394
|
+
require_relative "../agent" unless defined?(Parse::Agent)
|
|
395
|
+
# Strip the two identity kwargs from the passthrough: a Ruby double-splat
|
|
396
|
+
# that repeats an explicit keyword WINS, so user_agent(client: master)
|
|
397
|
+
# or user_agent(session_token: other) would otherwise silently defeat the
|
|
398
|
+
# whole point (scoping to the caller). The scoping is non-negotiable here.
|
|
399
|
+
opts = opts.except(:session_token, :client)
|
|
400
|
+
Parse::Agent.new(session_token: @session_token, client: user_client, **opts)
|
|
401
|
+
end
|
|
402
|
+
|
|
173
403
|
# @return [String] the name of the Parse class for this request.
|
|
174
404
|
def parse_class
|
|
175
405
|
return @webhook_class if @webhook_class.present?
|
|
@@ -230,6 +460,73 @@ module Parse
|
|
|
230
460
|
trigger? && @trigger_name.to_sym == :afterFind
|
|
231
461
|
end
|
|
232
462
|
|
|
463
|
+
# true if this is a beforeLogin webhook trigger request.
|
|
464
|
+
#
|
|
465
|
+
# NOTE: a +beforeLogin+ payload carries the user being authenticated as
|
|
466
|
+
# {#object} / {#parse_object} (a +_User+), NOT as {#user} -- the caller is
|
|
467
|
+
# not yet authenticated when the trigger fires, so {#user} is +nil+. (By
|
|
468
|
+
# +afterLogin+ both are populated and equal.) Reach for {#parse_object} to
|
|
469
|
+
# inspect the logging-in user during +beforeLogin+.
|
|
470
|
+
def before_login?
|
|
471
|
+
trigger? && @trigger_name.to_sym == :beforeLogin
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# true if this is a afterLogin webhook trigger request.
|
|
475
|
+
def after_login?
|
|
476
|
+
trigger? && @trigger_name.to_sym == :afterLogin
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# true if this is a afterLogout webhook trigger request. The logged-out
|
|
480
|
+
# session is carried as {#object} / {#parse_object} (a +_Session+).
|
|
481
|
+
def after_logout?
|
|
482
|
+
trigger? && @trigger_name.to_sym == :afterLogout
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# true if this is a beforePasswordResetRequest webhook trigger request.
|
|
486
|
+
# The target user is carried as {#object} / {#parse_object} (a +_User+).
|
|
487
|
+
def before_password_reset_request?
|
|
488
|
+
trigger? && @trigger_name.to_sym == :beforePasswordResetRequest
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# true if this is a LiveQuery beforeConnect webhook trigger request.
|
|
492
|
+
# Connection-global: carries no {#object}; the className is the +@Connect+
|
|
493
|
+
# sentinel and the caller's token (if any) is in {#session_token}.
|
|
494
|
+
def before_connect?
|
|
495
|
+
trigger? && @trigger_name.to_sym == :beforeConnect
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# true if this is a LiveQuery beforeSubscribe webhook trigger request.
|
|
499
|
+
# Shaped like beforeFind: carries a {#query} (see {#parse_query}) and the
|
|
500
|
+
# className comes from the request path, not the body.
|
|
501
|
+
def before_subscribe?
|
|
502
|
+
trigger? && @trigger_name.to_sym == :beforeSubscribe
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# true if this is a LiveQuery afterEvent webhook trigger request. The
|
|
506
|
+
# event type (create/enter/update/leave/delete) is in {#event}.
|
|
507
|
+
def after_event?
|
|
508
|
+
trigger? && @trigger_name.to_sym == :afterEvent
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# true if this is one of the authentication-side triggers
|
|
512
|
+
# (beforeLogin / afterLogin / afterLogout / beforePasswordResetRequest).
|
|
513
|
+
# These carry a +_User+ / +_Session+ as {#object} but are NOT object
|
|
514
|
+
# save/delete triggers: no ActiveModel save/create/destroy callbacks run
|
|
515
|
+
# for them, and Parse Server ignores the response body (the only way to
|
|
516
|
+
# affect a +before*+ one is to deny it -- see the webhook router).
|
|
517
|
+
def auth_trigger?
|
|
518
|
+
before_login? || after_login? || after_logout? || before_password_reset_request?
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# true if this is one of the LiveQuery triggers (beforeConnect /
|
|
522
|
+
# beforeSubscribe / afterEvent). Parse Server delivers these over an HTTP
|
|
523
|
+
# webhook only in a co-located single-process LiveQuery setup;
|
|
524
|
+
# +beforeConnect+ in particular carries a live client and is effectively
|
|
525
|
+
# in-process-only. See the webhooks guide.
|
|
526
|
+
def live_query_trigger?
|
|
527
|
+
before_connect? || before_subscribe? || after_event?
|
|
528
|
+
end
|
|
529
|
+
|
|
233
530
|
# true if this request is a trigger that contains an object.
|
|
234
531
|
def object?
|
|
235
532
|
trigger? && @object.present?
|
|
@@ -321,9 +618,57 @@ module Parse
|
|
|
321
618
|
return o
|
|
322
619
|
end
|
|
323
620
|
|
|
324
|
-
# afterSave on a CREATE
|
|
325
|
-
#
|
|
326
|
-
#
|
|
621
|
+
# afterSave on a CREATE: there is no prior persisted state, so every
|
|
622
|
+
# populated data field is new. Build symmetry with the UPDATE path above
|
|
623
|
+
# by seeding the identity / system fields (objectId, timestamps, ACL,
|
|
624
|
+
# className, plus the credential / permission keys) into a pristine
|
|
625
|
+
# object, then overlaying the full object with dirty tracking. Because
|
|
626
|
+
# the overlay's protected_set is exactly the seed key set, the system
|
|
627
|
+
# fields come ONLY from the clean seed and the overlay touches ONLY
|
|
628
|
+
# declared data properties (nil -> value -> changed) -- so `*_changed?`
|
|
629
|
+
# reports every field the create populated while createdAt / updatedAt /
|
|
630
|
+
# ACL stay clean. This lets handlers key off dirty tracking uniformly
|
|
631
|
+
# across create and update (e.g. building a sync payload from changed
|
|
632
|
+
# fields). Credentials / _rperm / _wperm / authData / roles are filtered
|
|
633
|
+
# from the overlay (seeded read-only, never marked changed), and an
|
|
634
|
+
# after-trigger response is only true/false, so nothing here can persist
|
|
635
|
+
# a forged field.
|
|
636
|
+
if after_save? && @object.is_a?(Hash)
|
|
637
|
+
seed_keys = Parse::Properties::PROTECTED_INITIALIZE_KEYS +
|
|
638
|
+
%w[objectId createdAt updatedAt ACL className __type]
|
|
639
|
+
seed = @object.slice(*seed_keys)
|
|
640
|
+
o = Parse::Object.build seed, parse_class
|
|
641
|
+
# `build` applies declared `default:` values onto the seed and then
|
|
642
|
+
# clears changes, baking each default into the "pristine" baseline.
|
|
643
|
+
# Without correction, the overlay's dirty guard (`unless val ==
|
|
644
|
+
# current`) would SUPPRESS marking any create value equal to its
|
|
645
|
+
# default (e.g. status: "draft", count: 0, archived: false), silently
|
|
646
|
+
# defeating this branch's whole purpose. Reset the default-bearing
|
|
647
|
+
# ivars to nil for the fields the overlay is about to set, then
|
|
648
|
+
# re-clear, so the overlay's guard sees a differing current ivar and
|
|
649
|
+
# marks every populated data field changed. (`*_changed?` / `changes`
|
|
650
|
+
# / `changed` then report the field; for a defaulted field the
|
|
651
|
+
# reported prior value is the default rather than nil, since the
|
|
652
|
+
# getter re-derives the default — its prior *effective* value.)
|
|
653
|
+
# `defaults_list` never contains the seeded system fields
|
|
654
|
+
# (objectId/createdAt/updatedAt/ACL), so this cannot disturb them;
|
|
655
|
+
# defaults for fields ABSENT from the payload are left intact so their
|
|
656
|
+
# value still reads through.
|
|
657
|
+
fmap = o.class.respond_to?(:field_map) ? o.class.field_map : {}
|
|
658
|
+
o.class.defaults_list.each do |k|
|
|
659
|
+
wire = (fmap[k] || k).to_s
|
|
660
|
+
next unless @object.key?(wire) || @object.key?(k.to_s)
|
|
661
|
+
o.instance_variable_set(:"@#{k}", nil)
|
|
662
|
+
end
|
|
663
|
+
o.clear_changes!
|
|
664
|
+
o.apply_attributes! @object, dirty_track: true, protected_set: seed_keys
|
|
665
|
+
return o
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Every other trigger (afterDelete, afterFind, and any before* path that
|
|
669
|
+
# did not match above): the full object as the server sent it.
|
|
670
|
+
# createdAt/updatedAt survive (only credentials are scrubbed), so
|
|
671
|
+
# `new?` / `existed?` read correctly.
|
|
327
672
|
Parse::Object.build(@object, parse_class)
|
|
328
673
|
end
|
|
329
674
|
|
|
@@ -340,6 +685,49 @@ module Parse
|
|
|
340
685
|
raise Parse::Webhooks::ResponseError, msg
|
|
341
686
|
end
|
|
342
687
|
|
|
688
|
+
# Register a block to run **after** this webhook's response has been sent
|
|
689
|
+
# to Parse Server, off the client's critical path. Use it to do work that
|
|
690
|
+
# should not add latency to the save/function the client is waiting on —
|
|
691
|
+
# search indexing, cache warming, fan-out notifications.
|
|
692
|
+
#
|
|
693
|
+
# The handler still returns its value synchronously (the response Parse
|
|
694
|
+
# Server acts on); the deferred block runs afterward. When the SDK is
|
|
695
|
+
# mounted under a server that supports `rack.after_reply` (Puma, Unicorn)
|
|
696
|
+
# the block runs once the response is flushed to the socket, on the same
|
|
697
|
+
# worker thread; otherwise it runs in a detached thread. Each block is
|
|
698
|
+
# isolated, so one raising neither affects the response nor the others.
|
|
699
|
+
#
|
|
700
|
+
# Parse::Webhooks.route :after_save, :Post do
|
|
701
|
+
# post = parse_object
|
|
702
|
+
# after_response { SearchIndex.reindex(post.id) }
|
|
703
|
+
# post
|
|
704
|
+
# end
|
|
705
|
+
#
|
|
706
|
+
# `self` inside the block is this payload (it closes over the handler's
|
|
707
|
+
# scope), so `parse_object`, `params`, etc. remain available. Note the
|
|
708
|
+
# block runs in-process and does not survive a worker restart — for work
|
|
709
|
+
# that *must* happen, hand it to a durable job queue instead. Deferred
|
|
710
|
+
# callbacks fire only when the payload is processed through the mounted
|
|
711
|
+
# `Parse::Webhooks` Rack app.
|
|
712
|
+
#
|
|
713
|
+
# @yield the work to run after the response is sent.
|
|
714
|
+
# @return [Boolean] true if a block was registered.
|
|
715
|
+
def after_response(&block)
|
|
716
|
+
return false unless block_given?
|
|
717
|
+
@deferred_callbacks ||= []
|
|
718
|
+
@deferred_callbacks << block
|
|
719
|
+
true
|
|
720
|
+
end
|
|
721
|
+
alias_method :defer, :after_response
|
|
722
|
+
|
|
723
|
+
# @!visibility private
|
|
724
|
+
# The blocks registered via {#after_response}; drained by the Rack app
|
|
725
|
+
# ({Parse::Webhooks.dispatch_deferred}) after the response is finished.
|
|
726
|
+
# @return [Array<Proc>]
|
|
727
|
+
def deferred_callbacks
|
|
728
|
+
@deferred_callbacks ||= []
|
|
729
|
+
end
|
|
730
|
+
|
|
343
731
|
# @return [Parse::Query] the Parse query for a beforeFind trigger.
|
|
344
732
|
def parse_query
|
|
345
733
|
return nil unless parse_class.present? && @query.is_a?(Hash)
|