parse-stack-next 5.3.0 → 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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -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 }.freeze
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,31 @@ 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
67
94
  # @!attribute [r] session_token
68
95
  # The caller's live Parse session token, captured from the incoming
69
96
  # webhook payload (`user.sessionToken`) before credentials are scrubbed
@@ -85,10 +112,24 @@ module Parse
85
112
  # You would normally never create a {Parse::Webhooks::Payload} object since it is automatically
86
113
  # provided to you when using Parse::Webhooks.
87
114
  # @see Parse::Webhooks
88
- def initialize(hash = {})
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)
89
126
  hash = JSON.parse(hash, max_nesting: 20) if hash.is_a?(String)
90
127
  hash = Hash[hash.map { |k, v| [k.to_s.underscore.to_sym, v] }]
91
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?
92
133
  @master = hash[:master]
93
134
  # Capture the caller's session token from the *unscrubbed* user hash
94
135
  # before scrub_credentials strips it below. Parse Server includes
@@ -99,6 +140,16 @@ module Parse
99
140
  # still letting a handler opt in to acting as the calling user via
100
141
  # #session_token / #user_client / #user_agent.
101
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
102
153
  # Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
103
154
  # Parse Server and, when a webhook key is configured (the default; see
104
155
  # Parse::Webhooks.allow_unauthenticated for the opt-out used in tests /
@@ -125,14 +176,42 @@ module Parse
125
176
  @params = hash[:params]
126
177
  @params = @params.with_indifferent_access if @params.is_a?(Hash)
127
178
  @function_name = hash[:function_name]
128
- @object = self.class.scrub_credentials(hash[:object])
129
179
  @trigger_name = hash[:trigger_name]
130
- @original = self.class.scrub_credentials(hash[:original])
131
- @update = self.class.scrub_credentials(hash[:update]) || {}
132
- # Added for beforeFind and afterFind triggers
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.
133
195
  @query = hash[:query]
134
- @objects = hash[:objects] || []
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
135
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 = []
136
215
  end
137
216
 
138
217
  # @!visibility private
@@ -166,6 +245,62 @@ module Parse
166
245
  end
167
246
  end
168
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
+
169
304
  # @!visibility private
170
305
  # Pulls the caller's session token out of the (unscrubbed) +user+ hash.
171
306
  # Parse Server sends it as the camelCase string key +sessionToken+; this
@@ -325,6 +460,73 @@ module Parse
325
460
  trigger? && @trigger_name.to_sym == :afterFind
326
461
  end
327
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
+
328
530
  # true if this request is a trigger that contains an object.
329
531
  def object?
330
532
  trigger? && @object.present?
@@ -483,6 +685,49 @@ module Parse
483
685
  raise Parse::Webhooks::ResponseError, msg
484
686
  end
485
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
+
486
731
  # @return [Parse::Query] the Parse query for a beforeFind trigger.
487
732
  def parse_query
488
733
  return nil unless parse_class.present? && @query.is_a?(Hash)