parse-stack-next 5.8.0 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +318 -0
  3. data/README.md +25 -19
  4. data/docs/caching.md +50 -0
  5. data/docs/mcp_guide.md +15 -3
  6. data/docs/webhooks_guide.md +59 -7
  7. data/lib/parse/acl_scope.rb +71 -9
  8. data/lib/parse/agent/describe.rb +8 -2
  9. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  10. data/lib/parse/agent.rb +205 -20
  11. data/lib/parse/atlas_search/index_manager.rb +5 -1
  12. data/lib/parse/atlas_search.rb +49 -1
  13. data/lib/parse/authorization.rb +235 -3
  14. data/lib/parse/cache/sub_cache.rb +26 -0
  15. data/lib/parse/client/authentication.rb +19 -1
  16. data/lib/parse/client/batch.rb +35 -2
  17. data/lib/parse/client.rb +26 -2
  18. data/lib/parse/clp_scope.rb +37 -3
  19. data/lib/parse/live_query/client.rb +72 -2
  20. data/lib/parse/lock_backend.rb +4 -3
  21. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  22. data/lib/parse/model/associations/has_many.rb +11 -0
  23. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  24. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  25. data/lib/parse/model/classes/role.rb +23 -0
  26. data/lib/parse/model/classes/session.rb +279 -19
  27. data/lib/parse/model/classes/user.rb +33 -12
  28. data/lib/parse/model/core/actions.rb +19 -4
  29. data/lib/parse/model/core/fetching.rb +29 -2
  30. data/lib/parse/model/core/field_guards.rb +16 -8
  31. data/lib/parse/model/object.rb +52 -1
  32. data/lib/parse/model/push.rb +11 -1
  33. data/lib/parse/mongodb.rb +19 -1
  34. data/lib/parse/query/constraint.rb +1 -1
  35. data/lib/parse/query/constraints.rb +31 -27
  36. data/lib/parse/query.rb +793 -55
  37. data/lib/parse/stack/version.rb +1 -1
  38. data/lib/parse/stack.rb +19 -0
  39. data/lib/parse/vector_search/hybrid.rb +74 -15
  40. data/lib/parse/vector_search.rb +214 -7
  41. data/lib/parse/webhooks/payload.rb +43 -0
  42. data/lib/parse/webhooks.rb +312 -9
  43. metadata +1 -1
@@ -374,7 +374,11 @@ module Parse
374
374
  if class_name.is_a?(Parse::Query)
375
375
  query = class_name
376
376
  class_name = query.table
377
- where = query.compile_where
377
+ where = query.compile_rest_where
378
+ # The subscription runs under the query's own session (or its
379
+ # `become` client's); a different explicit token raises, and so
380
+ # does a query bound to another application than this client.
381
+ session_token = query.live_query_session_token(session_token, live_query_client: self)
378
382
  end
379
383
 
380
384
  # Refuse server-side-JS / data-mutating operators in the `where`
@@ -1007,6 +1011,27 @@ module Parse
1007
1011
  end
1008
1012
  end
1009
1013
 
1014
+ # Release the latch set when this admin client first connected inside
1015
+ # {Parse.without_master_key}. Until this is called, every later
1016
+ # connect and reconnect withholds the master key. The next connect
1017
+ # made outside the block then sends it again, elevating every
1018
+ # subscription on the socket (a warning names how many).
1019
+ # @return [void]
1020
+ def allow_master_key_connection!
1021
+ return unless @master_key_withheld
1022
+ @master_key_withheld = false
1023
+ @master_key_latch_released = true
1024
+ nil
1025
+ end
1026
+
1027
+ # @return [Boolean] whether this client withholds its master key on
1028
+ # every connect because it first connected inside
1029
+ # {Parse.without_master_key}.
1030
+ def master_key_withheld?
1031
+ @master_key_withheld == true
1032
+ end
1033
+ public :allow_master_key_connection!, :master_key_withheld?
1034
+
1010
1035
  # Resubscribe all pending subscriptions
1011
1036
  def resubscribe_all
1012
1037
  subs = @monitor.synchronize { @subscriptions.values.dup }
@@ -1031,8 +1056,32 @@ module Parse
1031
1056
  # bypasses ACL/CLP/protectedFields. Sending it unconditionally
1032
1057
  # (the pre-5.1.0 behavior) silently elevated session-token
1033
1058
  # subscriptions the caller believed were scoped.
1034
- if admin_connection?
1059
+ if admin_connection? && Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
1060
+ # Inside `Parse.without_master_key` the connect frame carries no
1061
+ # master key, as REST requests in the block do not. The socket is
1062
+ # then ACL-scoped for its whole lifetime, and the choice is latched:
1063
+ # a later reconnect (including an explicit `connect` made outside
1064
+ # the block) keeps withholding it, so subscriptions created on
1065
+ # this socket are never silently elevated.
1066
+ @master_key_withheld = true
1067
+ @connected_as_admin = false
1068
+ warn_master_key_withheld_once
1069
+ elsif admin_connection? && @master_key_withheld
1070
+ @connected_as_admin = false
1071
+ warn_master_key_withheld_once
1072
+ elsif admin_connection?
1073
+ if @master_key_latch_released
1074
+ @master_key_latch_released = false
1075
+ pending = @monitor.synchronize { @subscriptions.size }
1076
+ if pending.positive?
1077
+ warn "[Parse::LiveQuery:SECURITY] reconnecting with the master key after " \
1078
+ "allow_master_key_connection!: #{pending} existing subscription(s), " \
1079
+ "including any created inside Parse.without_master_key, now bypass " \
1080
+ "ACL/CLP."
1081
+ end
1082
+ end
1035
1083
  message[:masterKey] = @master_key
1084
+ @connected_as_admin = true
1036
1085
  warn_master_key_connection_once
1037
1086
  elsif @use_master_key
1038
1087
  # Opted into admin mode but no usable master key is present —
@@ -1045,6 +1094,16 @@ module Parse
1045
1094
  send_message(message)
1046
1095
  end
1047
1096
 
1097
+ # One-time warning that an admin connection was opened without its
1098
+ # master key because `Parse.without_master_key` was active.
1099
+ def warn_master_key_withheld_once
1100
+ return if @master_key_withheld_warning_emitted
1101
+ @master_key_withheld_warning_emitted = true
1102
+ warn "[Parse::LiveQuery] admin connection (use_master_key: true) opened " \
1103
+ "inside Parse.without_master_key: the connect frame carries no master " \
1104
+ "key, so every subscription on this connection is ACL-scoped."
1105
+ end
1106
+
1048
1107
  # One-time loud warning that this connection bypasses ACL/CLP for
1049
1108
  # every subscription. Master-key LiveQuery is connection-level, so
1050
1109
  # this is the only place the risk can be surfaced.
@@ -1063,6 +1122,17 @@ module Parse
1063
1122
  # silently disagree with the connection's actual authorization.
1064
1123
  # Both are "you think you're scoped (or elevated) but you're not."
1065
1124
  def warn_subscription_scope_mismatch(use_master_key, session_token)
1125
+ if @connected_as_admin && Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled? &&
1126
+ !@block_on_admin_warning_emitted
1127
+ # Parse Server fixes master-key authorization per connection at
1128
+ # connect time, so a socket that connected with the master key
1129
+ # stays elevated even for a subscription made inside the block.
1130
+ @block_on_admin_warning_emitted = true
1131
+ warn "[Parse::LiveQuery:SECURITY] subscribe inside Parse.without_master_key " \
1132
+ "on a connection that already connected with the master key: Parse " \
1133
+ "Server authorizes per connection, so this subscription still " \
1134
+ "bypasses ACL/CLP. Use a non-admin client for streams inside the block."
1135
+ end
1066
1136
  if use_master_key && !admin_connection?
1067
1137
  return if @per_sub_master_key_warning_emitted
1068
1138
  @per_sub_master_key_warning_emitted = true
@@ -164,9 +164,10 @@ module Parse
164
164
  # a holder whose lease expired and was re-acquired by someone else
165
165
  # can never delete the new holder's key. Falls back to a
166
166
  # best-effort GET-then-DEL for raw-Moneta stores, where the
167
- # worst-case cross-holder-delete race is bounded by the short TTL
168
- # (callers clamp `ttl:` to ≤ 30s) — documented residual risk for
169
- # the non-Redis path.
167
+ # worst-case cross-holder-delete race is bounded by the lease TTL.
168
+ # `Parse::Lock` clamps `ttl:` to 30s and the `first_or_create!`
169
+ # create-lock to 300s. This is a documented residual risk for the
170
+ # non-Redis path.
170
171
  #
171
172
  # @param store [Object] Moneta-shaped store.
172
173
  # @param key [String] cache key.
@@ -271,9 +271,10 @@ module Parse
271
271
  alias_method :delete, :remove
272
272
 
273
273
  # Atomically adds all items to the array field. The request is sent
274
- # directly to the Parse backend. On success the local collection is
275
- # updated to match (the items are appended) without marking the field
276
- # as changed, so a later save does not overwrite the server array. On
274
+ # directly to the Parse backend. On success the local collection takes
275
+ # the array the server returned (or appends the items when the reply
276
+ # does not include the field) without marking the field as changed, so
277
+ # a later save does not overwrite the server array. On
277
278
  # an owner that has not been saved yet there is nothing to update on
278
279
  # the server, so the items are added locally as a normal change and
279
280
  # sent with the next save.
@@ -509,11 +510,11 @@ module Parse
509
510
  # Read the owner's dirty state before touching the items, so a change
510
511
  # that was already pending is still sent by the next save.
511
512
  was_dirty = delegate_field_dirty?
512
- # A clean plain array adopts the array the server returned, so a local
513
- # copy that was already out of date is corrected. With unsaved local
514
- # edits pending, adopting it would discard them, so the operation is
515
- # applied locally instead (as it is for pointer collections, or a reply
516
- # without the field) and the pending edits are still sent on save.
513
+ # A clean array adopts the array the server returned, so a local copy
514
+ # that was already out of date is corrected. With unsaved local edits
515
+ # pending, adopting it would discard them, so the operation is applied
516
+ # locally instead (as it is for a reply without the field) and the
517
+ # pending edits are still sent on save.
517
518
  server = was_dirty ? nil : server_array_after_op
518
519
  @collection = server || yield(collection.to_a.dup, items)
519
520
  @loaded = true
@@ -528,13 +529,20 @@ module Parse
528
529
  end
529
530
 
530
531
  # The field's new array from the delegate's last atomic operation, when
531
- # this is a plain array proxy and the server returned it.
532
+ # the server returned it.
532
533
  # @return [Array, nil]
533
534
  def server_array_after_op
534
- return nil unless instance_of?(Parse::CollectionProxy)
535
535
  return nil unless @delegate.respond_to?(:_last_operation_value, true)
536
536
  value = @delegate.send(:_last_operation_value, @key)
537
- value.is_a?(Array) ? Parse::Properties.deep_copy_value(value) : nil
537
+ value.is_a?(Array) ? adopt_server_items(value) : nil
538
+ end
539
+
540
+ # The local items for an array the server returned, or nil to apply the
541
+ # operation locally instead.
542
+ # @param value [Array] the field's array from the server's reply.
543
+ # @return [Array, nil]
544
+ def adopt_server_items(value)
545
+ Parse::Properties.deep_copy_value(value)
538
546
  end
539
547
 
540
548
  # Convert items to pointer format for atomic operations.
@@ -555,6 +555,17 @@ module Parse
555
555
  warn "[#{self.class}] has_many :#{key} expected className=#{klassName.inspect}, ignoring incoming className=#{val[Parse::Model::KEY_CLASS_NAME].inspect}"
556
556
  end
557
557
  if val.is_a?(Hash) && val["__type"] == "Relation"
558
+ current = instance_variable_get(ivar)
559
+ # A server descriptor carries no membership. When the current
560
+ # proxy holds staged additions or removals (a fetch of a record
561
+ # with unsaved relation changes), keep it so they are not lost.
562
+ if track != true && current.is_a?(Parse::RelationCollectionProxy) &&
563
+ current.staged_changes?
564
+ # The loaded list predates this fetch. Unload it so the next
565
+ # read queries the server and re-applies the staged changes.
566
+ current.reset! if current.loaded?
567
+ return current
568
+ end
558
569
  relation_objects = val["objects"] || []
559
570
  val = Parse::RelationCollectionProxy.new relation_objects, delegate: self, key: key, parse_class: klassName
560
571
  elsif val.is_a?(Hash) && val["__op"] == "AddRelation" && val["objects"].present?
@@ -190,6 +190,42 @@ module Parse
190
190
  end
191
191
  end
192
192
 
193
+ # The server returns the field's array as pointer hashes. Each entry
194
+ # becomes an object of the declared class, reusing the local object with
195
+ # the same id so fetched data is kept. The array is adopted only when
196
+ # the declared class is a registered model and every entry is a pointer
197
+ # to that class; anything else (another class, a bare string, a value
198
+ # that is not a pointer) makes the operation apply locally instead,
199
+ # rather than relabeling the entry as the declared class.
200
+ # @return [Array<Parse::Pointer>, nil]
201
+ def adopt_server_items(value)
202
+ return nil if @parse_class.blank? || Parse::Model.find_class(@parse_class).nil?
203
+ local_by_id = {}
204
+ collection.to_a.each do |item|
205
+ id = item.respond_to?(:id) ? item.id : nil
206
+ local_by_id[id] ||= item if id.present?
207
+ end
208
+ value.map do |entry|
209
+ class_name, object_id = server_pointer_parts(entry)
210
+ return nil unless object_id.is_a?(String) && object_id.present?
211
+ return nil unless Parse::Model.same_parse_class?(class_name, @parse_class)
212
+ local_by_id[object_id] || typecast_item(entry) || (return nil)
213
+ end
214
+ end
215
+
216
+ # @return [Array(String, String), nil] the className and objectId of a
217
+ # pointer entry from a server reply, or nil when it is not a pointer.
218
+ def server_pointer_parts(entry)
219
+ case entry
220
+ when Parse::Pointer
221
+ [entry.parse_class, entry.id]
222
+ when Hash
223
+ type = entry["__type"] || entry[:__type]
224
+ return nil unless %w[Pointer Object].include?(type.to_s)
225
+ [entry["className"] || entry[:className], entry["objectId"] || entry[:objectId]]
226
+ end
227
+ end
228
+
193
229
  # @return [Parse::Pointer, nil] the item as a Parse object, or nil.
194
230
  def typecast_item(item)
195
231
  case item
@@ -24,18 +24,19 @@ module Parse
24
24
  # get matching items within the relation collection.
25
25
  #
26
26
  # When creating a Relation proxy, all the delegate methods defined in the superclasses
27
- # need to be implemented, in addition to a few others with the key parameter:
28
- # _relation_query and _commit_relation_updates . :'key'_relation_query should return a
27
+ # need to be implemented, in addition to :'key'_relation_query, which should return a
29
28
  # Parse::Query object that is properly tied to the foreign table class related to this object column.
30
29
  # Example, if an Artist has many Song objects, then the query to be returned by this method
31
30
  # should be a Parse::Query for the class 'Song'.
32
31
  # Because relation changes are separate from object changes, you can call save on a
33
- # relation collection to save the current add and remove operations. Because the delegate needs
34
- # to be informed of the changes being committed, it will be notified
35
- # through :'key'_commit_relation_updates message. The delegate is also in charge of
36
- # clearing out the change information for the collection if saved successfully.
32
+ # relation collection to send only its staged add and remove operations, through the
33
+ # owner's atomic relation operations.
37
34
  # @see PointerCollectionProxy
38
35
  class RelationCollectionProxy < PointerCollectionProxy
36
+ # @!visibility private
37
+ # Default for {#save}'s `session:`: keep the owner's session as it is.
38
+ SESSION_UNSET = Object.new.freeze
39
+
39
40
  define_attribute_methods :additions, :removals
40
41
  # @!attribute [r] removals
41
42
  # The objects that have been newly removed to this collection
@@ -200,7 +201,7 @@ module Parse
200
201
  return false unless @delegate.respond_to?(:op_add_relation!)
201
202
  items = typecast_items(items)
202
203
  return true if items.empty?
203
- return add(*items) && true unless owner_saved?
204
+ return add(*items) && true unless owner_persisted?
204
205
  return false unless @delegate.send(:op_add_relation!, @key, items.parse_pointers)
205
206
  items.each do |item|
206
207
  @removals.delete(item)
@@ -223,7 +224,7 @@ module Parse
223
224
  return false unless @delegate.respond_to?(:op_remove_relation!)
224
225
  items = typecast_items(items, strict: false)
225
226
  return true if items.empty?
226
- return remove(*items) && true unless owner_saved?
227
+ return remove(*items) && true unless owner_persisted?
227
228
  return false unless @delegate.send(:op_remove_relation!, @key, items.parse_pointers)
228
229
  items.each do |item|
229
230
  @additions.delete(item)
@@ -251,11 +252,66 @@ module Parse
251
252
  @collection
252
253
  end
253
254
 
254
- # Save the changes to the relation
255
- def save
256
- unless @removals.empty? && @additions.empty?
257
- forward :"#{@key}_commit_relation_updates"
255
+ # @return [Boolean] true when additions or removals are staged and not
256
+ # yet sent.
257
+ def staged_changes?
258
+ @additions.any? || @removals.any?
259
+ end
260
+
261
+ # Drops the staged additions and removals and clears the dirty tracking,
262
+ # without sending anything. When operations were staged, the items are
263
+ # reloaded from the server on the next access, since the loaded list
264
+ # included them.
265
+ def clear_changes!
266
+ staged = staged_changes?
267
+ @additions = []
268
+ @removals = []
269
+ super
270
+ reset! if staged
271
+ @collection
272
+ end
273
+
274
+ # Sends the staged additions and removals of this relation without saving
275
+ # the rest of the owner. The owner must already be saved: an unsaved owner
276
+ # sends its relation additions when it is created.
277
+ #
278
+ # Removals and additions go out as two requests, removals first (each
279
+ # through the owner's atomic relation operation, so {Parse::Role} cache
280
+ # invalidation still runs). The two are not atomic: if the removals
281
+ # succeed and the additions fail, the removals stay applied, they are no
282
+ # longer staged, and the additions stay staged for a retry. An owner
283
+ # save sends relation changes the same way.
284
+ #
285
+ # The requests use the owner's session token from its last
286
+ # `save(session:)`, or the client's default credentials. Pass `session:`
287
+ # to send them as a specific user (a session token String or a
288
+ # {Parse::User}), or `session: nil` to send them with no session token.
289
+ # @param session [String, Parse::User, nil] the session to send the
290
+ # operations with, when given.
291
+ # @return [Boolean] whether the staged operations were applied. False
292
+ # when the owner is not saved yet or a request failed; operations that
293
+ # did not succeed stay staged.
294
+ def save(session: SESSION_UNSET)
295
+ return true if @additions.empty? && @removals.empty?
296
+ return false unless owner_persisted?
297
+ return false unless @delegate.respond_to?(:op_add_relation!) && @delegate.respond_to?(:op_remove_relation!)
298
+ applied = with_owner_session(session) do
299
+ if @removals.any?
300
+ next false unless @delegate.send(:op_remove_relation!, @key, @removals.parse_pointers)
301
+ @removals = []
302
+ end
303
+ if @additions.any?
304
+ next false unless @delegate.send(:op_add_relation!, @key, @additions.parse_pointers)
305
+ @additions = []
306
+ end
307
+ true
308
+ end
309
+ return false unless applied
310
+ clear_changes_information
311
+ if @delegate.respond_to?(:clear_attribute_changes, true)
312
+ @delegate.send(:clear_attribute_changes, [@key.to_s])
258
313
  end
314
+ true
259
315
  end
260
316
 
261
317
  # @see #add
@@ -273,6 +329,35 @@ module Parse
273
329
  !(@delegate.respond_to?(:id) && @delegate.id.blank?)
274
330
  end
275
331
 
332
+ # @return [Boolean] whether the owner exists on the server, so a write to
333
+ # its relation can be sent. Follows {Parse::Object#persisted?}: an
334
+ # id-only handle to a stored record counts, but an objectId assigned
335
+ # client-side during a create (`parse_reference precompute:`) does not
336
+ # until the create returns, and neither does a destroyed owner.
337
+ def owner_persisted?
338
+ return false unless owner_saved?
339
+ !@delegate.respond_to?(:persisted?) || @delegate.persisted?
340
+ end
341
+
342
+ # Run the block with the owner's session token set to `session`, then
343
+ # restore the owner's previous token. {SESSION_UNSET} runs it unchanged.
344
+ def with_owner_session(session)
345
+ return yield if session.equal?(SESSION_UNSET)
346
+ token = @delegate.send(:_validate_session_token!, session, :save)
347
+ had = @delegate.instance_variable_defined?(:@_session_token)
348
+ previous = @delegate.instance_variable_get(:@_session_token)
349
+ @delegate.instance_variable_set(:@_session_token, token)
350
+ begin
351
+ yield
352
+ ensure
353
+ if had
354
+ @delegate.instance_variable_set(:@_session_token, previous)
355
+ else
356
+ @delegate.remove_instance_variable(:@_session_token)
357
+ end
358
+ end
359
+ end
360
+
276
361
  # ActiveModel reads the current value when a change starts. Read the
277
362
  # items directly: going through {#collection} would query the whole
278
363
  # relation just to stage an add or remove.
@@ -279,6 +279,12 @@ module Parse
279
279
  # query/constraints) — none of those have a caller scope to
280
280
  # forward. The fast path is opt-in for performance-conscious
281
281
  # callers that can supply explicit authorization.
282
+ # @note Inside {Parse.without_master_key}, the Parse Server walk (no
283
+ # `master:` or `as:`) still reads `_Role` with the master key as SDK
284
+ # metadata, so it returns the full role-name closure of `user`, not
285
+ # only the publicly readable roles. The block guards against
286
+ # accidental master-key use; it does not scope this lookup. Pass
287
+ # `as:` for a scope-checked answer.
282
288
  # @example
283
289
  # names = Parse::Role.all_for_user(user, master: true) # admin/analytics
284
290
  # names = Parse::Role.all_for_user(user, as: current_user) # scope-checked
@@ -423,6 +429,20 @@ module Parse
423
429
  end
424
430
 
425
431
  def role_query_all(constraints, client: nil)
432
+ # Inside `Parse.without_master_key` the role graph is still read with
433
+ # the master key. It is metadata the SDK needs to enforce a scope, as
434
+ # Parse Server reads it itself; read without the key it returned only
435
+ # publicly readable roles, and that short closure was then cached for
436
+ # the user and served to every caller until it expired.
437
+ if Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
438
+ query = Parse::Role.query(constraints.reverse_merge(limit: :max))
439
+ query.client = client if client
440
+ # A master-key read: keep it out of the shared response cache.
441
+ query.cache = false
442
+ query.instance_variable_set(:@_metadata_master, true)
443
+ return query.results
444
+ end
445
+
426
446
  # No explicit client means the historical path, unchanged. This is not
427
447
  # only for compatibility: `Parse::Role.all` is what callers and tests
428
448
  # observe and stub, and routing around it when nothing asked us to
@@ -957,6 +977,9 @@ module Parse
957
977
  # another's role names.
958
978
  # @param strict [Boolean] re-raise role-query failures rather than returning
959
979
  # a partial parent closure.
980
+ # @note Inside {Parse.without_master_key} the walk still reads `_Role`
981
+ # with the master key as SDK metadata, so it returns the full parent
982
+ # closure, not only publicly readable roles.
960
983
  def all_parent_role_names(max_depth: 10, client: nil, strict: false)
961
984
  Parse::Role.expand_inheritance_upward(
962
985
  [self], max_depth: max_depth, client: client, strict: strict,