parse-stack-next 5.7.5 → 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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +856 -0
  3. data/README.md +15 -4
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +190 -14
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +318 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +290 -17
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
@@ -83,7 +83,38 @@ module Parse
83
83
  class Webhooks
84
84
  # The error to be raised in registered trigger or function webhook blocks that
85
85
  # will trigger the Parse::Webhooks application to return the proper error response.
86
- class ResponseError < StandardError; end
86
+ #
87
+ # An optional numeric `code` is carried on the exception and written into
88
+ # the error body as `"code"`. Note that Parse Server's HTTP webhook adapter
89
+ # (as of 9.10) reports every webhook error to the client as code 141
90
+ # (`SCRIPT_FAILED`) and does not forward a custom code; the code remains
91
+ # visible to in-process callers such as {Parse::Webhooks.run_function}.
92
+ class ResponseError < StandardError
93
+ # @return [Integer, nil] the Parse error code requested by the handler.
94
+ attr_reader :code
95
+
96
+ # @param message [String] the error message.
97
+ # @param code [Integer, nil] an optional Parse error code.
98
+ def initialize(message = nil, code: nil)
99
+ super(message)
100
+ @code = code
101
+ end
102
+ end
103
+
104
+ # The reply that tells Parse Server to keep a beforeSave write, or the
105
+ # afterFind rows, exactly as they were: a JSON object with no `success`
106
+ # key, so the adapter's `body.success` is `undefined`. A `null` success
107
+ # is not safe for beforeSave (see {Parse::Webhooks.before_save_reply}).
108
+ PASS_THROUGH_BODY = "{}"
109
+
110
+ # Keys never echoed back to Parse Server in a beforeSave reply. These are
111
+ # server-managed (`className`, timestamps) or credential material that a
112
+ # client write cannot legitimately carry.
113
+ # @!visibility private
114
+ BEFORE_SAVE_REPLY_SKIP_KEYS = %w[
115
+ className createdAt updatedAt
116
+ sessionToken session_token _hashed_password _password_history
117
+ ].freeze
87
118
 
88
119
  # The authentication-side triggers (local underscore form). These carry a
89
120
  # `_User` / `_Session` as the payload object but are NOT object save/delete
@@ -483,6 +514,12 @@ module Parse
483
514
  result = invoke_handler(payload, registry)
484
515
  end
485
516
 
517
+ if type == :after_find
518
+ # Parse Server can only keep or deny afterFind rows from an HTTP
519
+ # webhook (see after_find_reply!); the reply always passes them through.
520
+ return after_find_reply!(payload, result)
521
+ end
522
+
486
523
  if result.is_a?(Parse::Object)
487
524
  # if it is a Parse::Object, we will call the registered ActiveModel callbacks
488
525
  if type == :before_save
@@ -510,18 +547,37 @@ module Parse
510
547
  end
511
548
  end
512
549
  end
513
- # For before_save, return the changes payload (what Parse Server expects)
514
- result = result.changes_payload
550
+ # Parse Server REPLACES the write with the object a beforeSave
551
+ # webhook returns, so reply with the client's full write plus the
552
+ # handler's changes (or `nil`, "unchanged", when there are none).
553
+ result = before_save_reply(payload, result, include_create_defaults: true)
515
554
  elsif type == :before_delete
516
- result.run_callbacks(:destroy) { false }
555
+ # Run only the BEFORE phase of the destroy chain: the object is not
556
+ # deleted yet, so after_destroy belongs to the afterDelete trigger.
557
+ # A halted chain must deny the delete, and Parse Server only treats
558
+ # an `{error}` body as a denial.
559
+ unless trusted_ruby_initiated
560
+ if result.send(:run_before_phase_callbacks, :destroy) == false
561
+ raise Parse::Webhooks::ResponseError, "Delete halted by before_destroy callback"
562
+ end
563
+ end
517
564
  result = true
518
565
  end
519
566
  elsif type == :before_save && result == false
520
567
  # If webhook block returns false, halt the save by throwing an error
521
568
  raise Parse::Webhooks::ResponseError, "Save halted by before_save webhook"
522
- elsif type == :before_save && (result == true || result.nil?)
523
- # Open Source Parse server does not accept true results on before_save hooks.
524
- result = {}
569
+ elsif type == :before_save
570
+ # `true` / `nil` (or any non-Hash value) means "allow the write as
571
+ # sent". A Hash is a set of field overrides. Either way the reply is
572
+ # built from the client's write so nothing the client sent is lost;
573
+ # in-place edits to `parse_object` and field-guard reverts are
574
+ # carried along. `nil` tells Parse Server to keep the write as is.
575
+ overrides = result.is_a?(Hash) ? result : nil
576
+ result = before_save_reply(payload, payload&.memoized_parse_object, overrides: overrides)
577
+ elsif type == :before_delete && result == false
578
+ # Parse Server ignores a `{success:false}` body and deletes anyway;
579
+ # only an `{error}` body denies the delete.
580
+ raise Parse::Webhooks::ResponseError, "Delete halted by before_delete webhook"
525
581
  end
526
582
 
527
583
  # Auth- and LiveQuery-trigger dispatch (beforeLogin/afterLogin/
@@ -548,29 +604,10 @@ module Parse
548
604
  result = true
549
605
  end
550
606
 
551
- # Guard-injection: when a handler returns a Hash (or true/nil normalized
552
- # to {}) for a class with field_guards, Parse Server would otherwise
553
- # merge the response with the client's original payload and persist
554
- # the client-supplied values for guarded fields. Inject the pre-built
555
- # parse_object's changes_payload entries for any guarded field so the
556
- # response carries the appropriate revert (Delete op on create, prior
557
- # value on update). The Parse::Object return path already runs through
558
- # changes_payload on the same memoized instance and therefore needs no
559
- # extra injection.
560
- if type == :before_save && result.is_a?(Hash) && payload && payload.object?
561
- guard_klass = (className.present? && className != "*") ? Parse::Object.find_class(className) : nil
562
- if guard_klass && guard_klass.respond_to?(:field_guards) && guard_klass.field_guards.any?
563
- pre_obj = payload.parse_object # same memoized instance the pre-block step mutated
564
- if pre_obj.respond_to?(:changes_payload)
565
- guard_payload = pre_obj.changes_payload
566
- field_map = guard_klass.respond_to?(:field_map) ? guard_klass.field_map : {}
567
- guard_klass.field_guards.each_key do |field|
568
- remote = (field_map[field.to_sym] || field).to_s
569
- result[remote] = guard_payload[remote] if guard_payload.key?(remote)
570
- end
571
- end
572
- end
573
- end
607
+ # Field guards need no separate injection step: the pre-block step
608
+ # reverted the guarded fields on the memoized parse_object, and
609
+ # before_save_reply diffs that same instance against the client's
610
+ # write, so every revert is already in the reply.
574
611
 
575
612
  if type == :after_save && payload&.parse_object.present? && payload.parse_object.is_a?(Parse::Object)
576
613
  # The chained ActiveModel after_save/after_create callbacks are NOT
@@ -681,6 +718,294 @@ module Parse
681
718
  nil
682
719
  end
683
720
 
721
+ # Fires the chained ActiveModel after_destroy callbacks for an afterDelete
722
+ # delivery, exactly once per request (after both the class route and the
723
+ # `"*"` route ran), mirroring {run_after_save_chain}. Skipped for
724
+ # trusted-Ruby-initiated deletes, whose callbacks already ran locally
725
+ # around `destroy`, and when no afterDelete route is registered. The
726
+ # object is already gone, so a raising callback is logged and swallowed.
727
+ #
728
+ # @param payload [Parse::Webhooks::Payload] the afterDelete payload.
729
+ # @return [void]
730
+ def run_after_delete_chain(payload)
731
+ obj = nil
732
+ return unless payload&.after_delete?
733
+ obj = payload.parse_object
734
+ return unless obj.is_a?(Parse::Object)
735
+ return unless route_registered?(:after_delete, payload.parse_class)
736
+ return if payload.ruby_initiated? && payload.master? == true
737
+ obj.run_after_delete_callbacks
738
+ nil
739
+ rescue => e
740
+ warn "[Parse::Webhooks] afterDelete after_destroy callback raised for " \
741
+ "#{obj ? obj.class : "UnknownObject"}##{Parse::TerminalSafe.sanitize_line(obj&.id)} -- the object is " \
742
+ "already deleted; logging and continuing: #{e.class}: " \
743
+ "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(e.message))}"
744
+ nil
745
+ end
746
+
747
+ # Whether a handler is registered for a trigger on a class, either on the
748
+ # class itself or on the generic `"*"` route.
749
+ #
750
+ # @param type [Symbol, String] the trigger (or `:function`).
751
+ # @param class_name [String, nil] the class (or function) name.
752
+ # @return [Boolean]
753
+ def route_registered?(type, class_name)
754
+ type = type.to_s.underscore.to_sym
755
+ table = routes[type]
756
+ return false if table.blank?
757
+ return table[class_name.to_s].present? if type == :function
758
+ table[class_name.to_s].present? || table["*"].present?
759
+ end
760
+
761
+ # Build the object a beforeSave webhook replies with.
762
+ #
763
+ # Parse Server's HTTP webhook adapter REPLACES the pending write with
764
+ # whatever object a beforeSave webhook returns (`this.data =
765
+ # response.object`). Replying with only the handler's changes therefore
766
+ # erases every other field the client sent. This method returns:
767
+ #
768
+ # - `nil` when nothing differs from the client's write. The router
769
+ # replies with an empty object (`{}`, no `success` key), which Parse
770
+ # Server treats as "keep the write exactly as sent", preserving every
771
+ # atomic operator. (`{"success": null}` is NOT equivalent: Parse
772
+ # Server's beforeSave adapter checks `typeof result === "object"`,
773
+ # which is true for null, and then throws deleting a key from it.)
774
+ # - otherwise a Hash: the client's write (rebuilt from the payload, with
775
+ # `Increment` / `Add` / `Remove` / `Delete` / relation operators passed
776
+ # through untouched) with the handler's changes layered on top. A field
777
+ # the handler (or a field guard) returned to its stored value is
778
+ # dropped from the write rather than rewritten.
779
+ #
780
+ # Fields are compared against a fresh build of the payload, so only the
781
+ # fields the handler actually changed are encoded from the Ruby object.
782
+ # Two limits come from what Parse Server sends: an operator on a dotted
783
+ # sub-key (`"meta.count"`) reaches the webhook only as its resulting
784
+ # sub-document, and is written back that way when the reply is not
785
+ # `nil`; and a field the handler rewrites is written as an absolute
786
+ # value.
787
+ #
788
+ # @param payload [Parse::Webhooks::Payload] the beforeSave payload.
789
+ # @param obj [Parse::Object, nil] the handler's object (nil when none).
790
+ # @param overrides [Hash, nil] field values a handler returned as a Hash.
791
+ # @param include_create_defaults [Boolean] on a create, also write the
792
+ # object's dirty fields the client did not send (declared defaults,
793
+ # default ACL), matching what an SDK-side create sends.
794
+ # @return [Hash, nil] the reply object, or nil for "unchanged".
795
+ def before_save_reply(payload, obj, overrides: nil, include_create_defaults: false)
796
+ return nil unless payload && payload.object?
797
+ raw_object = payload.raw_object
798
+ return nil unless raw_object.is_a?(Hash)
799
+ raw_original = payload.raw_original
800
+ raw_original = nil unless raw_original.is_a?(Hash) && raw_original.present?
801
+
802
+ changes = {}
803
+ drops = []
804
+ if obj.is_a?(Parse::Object)
805
+ changes, drops = handler_field_changes(payload, obj, raw_original)
806
+ if include_create_defaults && raw_original.nil?
807
+ client_keys = raw_object.keys.map(&:to_s)
808
+ dirty = obj.changed.map(&:to_sym) & snapshot_fields(obj)
809
+ wire_values(obj, dirty).each do |remote, value|
810
+ next if client_keys.include?(remote) || changes.key?(remote)
811
+ changes[remote] = value
812
+ end
813
+ end
814
+ end
815
+ overrides = overrides.as_json if overrides.is_a?(Hash)
816
+ return nil if changes.empty? && drops.empty? && overrides.blank?
817
+
818
+ reply = client_write_data(raw_object, raw_original)
819
+ drops.each { |remote| reply.delete(remote) }
820
+ reply.merge!(changes)
821
+ reply.merge!(overrides.transform_keys(&:to_s)) if overrides.present?
822
+ reply
823
+ end
824
+
825
+ # The client's write, rebuilt from a beforeSave payload. Parse Server
826
+ # serializes the pending object with `toJSON()`, which reports every
827
+ # pending top-level operator as its operator hash, so the operators
828
+ # survive here as sent. On an update, a field whose value equals the
829
+ # stored one was not written by the client and is left out.
830
+ #
831
+ # @param raw_object [Hash] the unscrubbed `object` hash.
832
+ # @param raw_original [Hash, nil] the unscrubbed `original` hash.
833
+ # @return [Hash]
834
+ # @!visibility private
835
+ def client_write_data(raw_object, raw_original)
836
+ data = {}
837
+ raw_object.each do |key, value|
838
+ key = key.to_s
839
+ next if BEFORE_SAVE_REPLY_SKIP_KEYS.include?(key)
840
+ if raw_original
841
+ # Parse Server drops objectId from an update write itself.
842
+ next if key == Parse::Model::OBJECT_ID
843
+ next if raw_original.key?(key) && raw_original[key] == value
844
+ end
845
+ data[key] = value
846
+ end
847
+ data
848
+ end
849
+
850
+ # Diff the handler's object against a fresh build of the same payload.
851
+ #
852
+ # Only fields that are dirty on either object can differ: a field the
853
+ # client did not write and the handler did not touch is clean on both.
854
+ # Comparing just those keeps the getters off fields the payload never
855
+ # carried (which would otherwise autofetch).
856
+ #
857
+ # @return [Array(Hash, Array<String>)] the changed wire fields with their
858
+ # encoded values, and the wire fields to drop from the write because
859
+ # the handler returned them to their stored value.
860
+ # @!visibility private
861
+ def handler_field_changes(payload, obj, raw_original)
862
+ fresh = payload.unmemoized_parse_object
863
+ fresh = nil unless fresh.instance_of?(obj.class)
864
+ candidates = obj.changed.map(&:to_sym)
865
+ candidates |= fresh.changed.map(&:to_sym) if fresh
866
+ candidates &= snapshot_fields(obj)
867
+ after = wire_values(obj, candidates)
868
+ before = fresh ? wire_values(fresh, candidates) : {}
869
+
870
+ changes = {}
871
+ drops = []
872
+ after.each do |remote, value|
873
+ next if before.key?(remote) && before[remote] == value
874
+ if raw_original
875
+ stored = raw_original.key?(remote) ? raw_original[remote].as_json : nil
876
+ unchanged = raw_original.key?(remote) ? stored == value : (value.is_a?(Hash) && value["__op"] == "Delete")
877
+ if unchanged
878
+ drops << remote
879
+ next
880
+ end
881
+ end
882
+ changes[remote] = value
883
+ end
884
+
885
+ # Relation edits are tracked on the proxies, not as field values. A
886
+ # relation field whose pending operation differs from the client's
887
+ # was changed by the handler or reverted by a field guard: write the
888
+ # handler's operation, or drop the client's when none is left.
889
+ ops_after = relation_ops(obj)
890
+ ops_before = fresh ? relation_ops(fresh) : {}
891
+ (ops_after.keys | ops_before.keys).each do |remote|
892
+ next if ops_after[remote] == ops_before[remote]
893
+ if ops_after.key?(remote)
894
+ changes[remote] = ops_after[remote]
895
+ else
896
+ drops << remote
897
+ end
898
+ end
899
+ [changes, drops]
900
+ end
901
+
902
+ # Pending relation operations of an object, keyed by remote field.
903
+ # Additions win over removals for the same field, matching
904
+ # `changes_payload`.
905
+ # @!visibility private
906
+ def relation_ops(obj)
907
+ return {} unless obj.respond_to?(:relation_changes?) && obj.relation_changes?
908
+ additions, removals = obj.relation_change_operations.as_json
909
+ (removals || {}).merge(additions || {})
910
+ end
911
+
912
+ # Declared, non-base, non-relation fields of an object.
913
+ # @!visibility private
914
+ def snapshot_fields(obj)
915
+ klass = obj.class
916
+ return [] unless klass.respond_to?(:fields)
917
+ klass.fields.reject do |name, type|
918
+ Parse::Properties::BASE_KEYS.include?(name.to_sym) || type == :relation
919
+ end.keys.map(&:to_sym)
920
+ end
921
+
922
+ # Encode the given fields of an object in Parse wire form, keyed by
923
+ # remote field name, using the same encoder the SDK uses for a save
924
+ # (`attribute_updates`). A nil value encodes as a Delete operator.
925
+ # Autofetch is suspended so reading a field never reaches the network.
926
+ # @!visibility private
927
+ def wire_values(obj, fields)
928
+ return {} if fields.empty?
929
+ names = fields.map(&:to_s).freeze
930
+ prior_autofetch = obj.instance_variable_get(:@_autofetch_disabled)
931
+ obj.instance_variable_set(:@_autofetch_disabled, true)
932
+ obj.define_singleton_method(:changed) { names }
933
+ begin
934
+ obj.attribute_updates.as_json
935
+ ensure
936
+ singleton = obj.singleton_class
937
+ singleton.send(:remove_method, :changed) if singleton.method_defined?(:changed, false)
938
+ obj.instance_variable_set(:@_autofetch_disabled, prior_autofetch)
939
+ end
940
+ end
941
+
942
+ # Resolve an afterFind handler result.
943
+ #
944
+ # Parse Server's afterFind handling maps each returned row through
945
+ # `toJSONwithObjects`, which turns any plain JSON object (everything an
946
+ # HTTP webhook can send) into `{}`, and crashes on a non-array body. A
947
+ # missing result keeps the matched rows. So an HTTP afterFind can observe
948
+ # the rows or deny the query, but it cannot rewrite them. The reply is
949
+ # always `nil` (sent as `{}`, with no `success` key), and a handler that tries
950
+ # to drop or add rows is refused with an error rather than having the
951
+ # rows it meant to hide returned anyway.
952
+ #
953
+ # @param payload [Parse::Webhooks::Payload] the afterFind payload.
954
+ # @param result [Object] the handler's return value.
955
+ # @return [nil]
956
+ # @raise [Parse::Webhooks::ResponseError] when the handler returned
957
+ # `false` or a different set of rows.
958
+ # @!visibility private
959
+ def after_find_reply!(payload, result)
960
+ if result == false
961
+ raise Parse::Webhooks::ResponseError, "afterFind rejected by webhook handler"
962
+ end
963
+ if result.is_a?(Array) && payload
964
+ returned = result.map { |row| after_find_row_id(row) }
965
+ matched = Array(payload.objects).map { |row| after_find_row_id(row) }
966
+ unless returned.size == matched.size && returned.sort_by(&:to_s) == matched.sort_by(&:to_s)
967
+ warn "[Parse::Webhooks] an afterFind handler for " \
968
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_class)} returned a different " \
969
+ "set of rows. Parse Server cannot apply row changes from an HTTP afterFind " \
970
+ "webhook, so the find is denied instead. Filter in beforeFind, or deny with error!."
971
+ raise Parse::Webhooks::ResponseError,
972
+ "afterFind webhooks cannot filter or replace results"
973
+ end
974
+ end
975
+ nil
976
+ end
977
+
978
+ # @!visibility private
979
+ def after_find_row_id(row)
980
+ case row
981
+ when Hash then row["objectId"] || row[:objectId] || row["id"] || row[:id]
982
+ else row.respond_to?(:id) ? row.id : row
983
+ end
984
+ end
985
+
986
+ # The value sent as `success` for a routed (or unrouted) trigger result.
987
+ # `nil` for beforeSave / afterFind means "pass through": the Rack app
988
+ # then replies `{}` (see {PASS_THROUGH_BODY}).
989
+ #
990
+ # @param payload [Parse::Webhooks::Payload] the request payload.
991
+ # @param result [Object] the dispatch result.
992
+ # @return [Object] the success value.
993
+ # @!visibility private
994
+ def trigger_success_value(payload, result)
995
+ # beforeSave: nil means "keep the write as sent", a Hash replaces it.
996
+ return (result.is_a?(Hash) ? result : nil) if payload.before_save?
997
+ # afterFind: only "no result" is safe (keeps the rows); see after_find_reply!.
998
+ return nil if payload.after_find?
999
+ result.nil? ? true : result
1000
+ end
1001
+
1002
+ # Whether a dispatch result should be sent as {PASS_THROUGH_BODY}.
1003
+ # @!visibility private
1004
+ def pass_through_reply?(payload, result)
1005
+ result.nil? && payload.respond_to?(:trigger?) && payload.trigger? &&
1006
+ (payload.before_save? || payload.after_find?)
1007
+ end
1008
+
684
1009
  # Generates a success response for Parse Server.
685
1010
  # @param data [Object] the data to send back with the success.
686
1011
  # @return [Hash] a success data payload
@@ -690,9 +1015,12 @@ module Parse
690
1015
 
691
1016
  # Generates an error response for Parse Server.
692
1017
  # @param data [Object] the data to send back with the error.
1018
+ # @param code [Integer, nil] an optional Parse error code to include.
693
1019
  # @return [Hash] a error data payload
694
- def error(data = false)
695
- { error: data }.to_json
1020
+ def error(data = false, code = nil)
1021
+ body = { error: data }
1022
+ body[:code] = code unless code.nil?
1023
+ body.to_json
696
1024
  end
697
1025
 
698
1026
  # @!attribute key
@@ -862,6 +1190,14 @@ module Parse
862
1190
  return response.finish
863
1191
  end
864
1192
 
1193
+ # A trigger whose body names a different class than the one the
1194
+ # request was routed to (the URL path) is forged or misrouted. Refuse
1195
+ # it before any handler runs or any typed object is built from it.
1196
+ if payload.trigger? && payload.payload_class_mismatch?
1197
+ response.write error("Webhook payload class does not match the trigger class.")
1198
+ return response.finish
1199
+ end
1200
+
865
1201
  if self.logging.present?
866
1202
  # Everything interpolated below arrives in the webhook request body:
867
1203
  # the trigger/function names, the object id, and the whole payload are
@@ -887,7 +1223,14 @@ module Parse
887
1223
  begin
888
1224
  result = true
889
1225
  if payload.function? && payload.function_name.present?
1226
+ # An unknown function must fail. Answering success would tell the
1227
+ # caller that a function ran when nothing did.
1228
+ unless Parse::Webhooks.route_registered?(:function, payload.function_name)
1229
+ raise Parse::Webhooks::ResponseError,
1230
+ "Webhook function #{payload.function_name} is not registered."
1231
+ end
890
1232
  result = Parse::Webhooks.call_route(:function, payload.function_name, payload)
1233
+ result = true if result.nil?
891
1234
  elsif payload.trigger? && payload.parse_class.present? && payload.trigger_name.present?
892
1235
  # call hooks subscribed to the specific class
893
1236
  result = Parse::Webhooks.call_route(payload.trigger_name, payload.parse_class, payload)
@@ -896,25 +1239,42 @@ module Parse
896
1239
  generic_result = Parse::Webhooks.call_route(payload.trigger_name, "*", payload)
897
1240
  result = generic_result if generic_result.present? && result.nil?
898
1241
 
899
- # Fire the chained ActiveModel after_save/after_create callbacks
900
- # exactly once per delivery -- after BOTH route calls above -- so an
901
- # app that registers both a class route and a `"*"` route doesn't
902
- # double-fire them. No-op for every non-afterSave trigger.
1242
+ # Fire the chained ActiveModel after_save/after_create (or
1243
+ # after_destroy) callbacks exactly once per delivery -- after BOTH
1244
+ # route calls above -- so an app that registers both a class route
1245
+ # and a `"*"` route doesn't double-fire them. Each is a no-op for
1246
+ # every other trigger.
903
1247
  Parse::Webhooks.run_after_save_chain(payload)
1248
+ Parse::Webhooks.run_after_delete_chain(payload)
1249
+
1250
+ # An unrouted trigger (or a handler that returned nil) passes the
1251
+ # operation through unchanged, in the shape each trigger needs.
1252
+ result = Parse::Webhooks.trigger_success_value(payload, result)
1253
+ elsif payload.trigger?
1254
+ # A trigger the router cannot attribute to a class: pass it through
1255
+ # unchanged rather than failing the client's operation.
1256
+ if self.logging.present?
1257
+ puts "[Webhooks] --> Could not find mapping route for " \
1258
+ "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(payload.to_json))}"
1259
+ end
1260
+ result = Parse::Webhooks.trigger_success_value(payload, nil)
904
1261
  else
905
1262
  if self.logging.present?
906
1263
  puts "[Webhooks] --> Could not find mapping route for " \
907
1264
  "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(payload.to_json))}"
908
1265
  end
1266
+ raise Parse::Webhooks::ResponseError, "Unrecognized webhook request."
909
1267
  end
910
1268
 
911
- result = true if result.nil?
1269
+ body = pass_through_reply?(payload, result) ? PASS_THROUGH_BODY : success(result)
912
1270
  if self.logging.present?
1271
+ # The reply can echo the client's write (passwords, auth data,
1272
+ # tokens a handler returned), so it is redacted like the request.
913
1273
  puts "[Webhooks::Response] ----------------------------"
914
- puts Parse::TerminalSafe.sanitize(success(result))
1274
+ puts Parse::TerminalSafe.sanitize(Parse::Middleware::BodyBuilder.redact(body))
915
1275
  puts "----------------------------------------------------\n"
916
1276
  end
917
- response.write success(result)
1277
+ response.write body
918
1278
  # Schedule any after_response work to run once this reply is flushed,
919
1279
  # off the client's critical path. Registered on the success path so the
920
1280
  # deferred work overlaps a response Parse Server will act on.
@@ -930,7 +1290,19 @@ module Parse
930
1290
  puts "[Webhooks::ResponseError] >> #{Parse::TerminalSafe.sanitize_line(payload.function_name)}: " \
931
1291
  "#{Parse::TerminalSafe.sanitize_line(e.to_s)}"
932
1292
  end
933
- response.write error(e.to_s)
1293
+ code = e.respond_to?(:code) ? e.code : nil
1294
+ response.write error(e.to_s, code)
1295
+ return response.finish
1296
+ rescue StandardError => e
1297
+ # Anything else a handler (or the router) raised. Answer with a JSON
1298
+ # error so Parse Server denies the operation cleanly, and keep the
1299
+ # exception message out of the reply: it can quote record data or
1300
+ # internals. The log line carries the class and a redacted message.
1301
+ where = payload.trigger? ? "#{payload.trigger_name} #{payload.parse_class}" : payload.function_name
1302
+ warn "[Webhooks::Error] >> #{Parse::TerminalSafe.sanitize_line(where.to_s)}: #{e.class}: " \
1303
+ "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(e.message.to_s))}"
1304
+ response = Rack::Response.new
1305
+ response.write error("Webhook handler failed.")
934
1306
  return response.finish
935
1307
  end
936
1308
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.7.5
4
+ version: 5.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -251,8 +251,11 @@ files:
251
251
  - lib/parse/agent/constraint_translator.rb
252
252
  - lib/parse/agent/describe.rb
253
253
  - lib/parse/agent/errors.rb
254
+ - lib/parse/agent/field_names.rb
255
+ - lib/parse/agent/field_policy.rb
254
256
  - lib/parse/agent/log_levels.rb
255
257
  - lib/parse/agent/mcp_client.rb
258
+ - lib/parse/agent/mcp_deployments.rb
256
259
  - lib/parse/agent/mcp_dispatcher.rb
257
260
  - lib/parse/agent/mcp_rack_app.rb
258
261
  - lib/parse/agent/mcp_server.rb
@@ -284,6 +287,7 @@ files:
284
287
  - lib/parse/api/users.rb
285
288
  - lib/parse/atlas_search.rb
286
289
  - lib/parse/atlas_search/index_manager.rb
290
+ - lib/parse/atlas_search/protected_paths.rb
287
291
  - lib/parse/atlas_search/result.rb
288
292
  - lib/parse/atlas_search/search_builder.rb
289
293
  - lib/parse/atlas_search/session.rb
@@ -403,8 +407,10 @@ files:
403
407
  - lib/parse/query/ordering.rb
404
408
  - lib/parse/retrieval.rb
405
409
  - lib/parse/retrieval/agent_tool.rb
410
+ - lib/parse/retrieval/benchmark.rb
406
411
  - lib/parse/retrieval/chunk.rb
407
412
  - lib/parse/retrieval/chunker.rb
413
+ - lib/parse/retrieval/profiles.rb
408
414
  - lib/parse/retrieval/reranker.rb
409
415
  - lib/parse/retrieval/reranker/cohere.rb
410
416
  - lib/parse/retrieval/reranker/voyage.rb
@@ -429,6 +435,7 @@ files:
429
435
  - lib/parse/two_factor_auth/user_extension.rb
430
436
  - lib/parse/vector_search.rb
431
437
  - lib/parse/vector_search/hybrid.rb
438
+ - lib/parse/vector_search/index_definition.rb
432
439
  - lib/parse/webhooks.rb
433
440
  - lib/parse/webhooks/payload.rb
434
441
  - lib/parse/webhooks/registration.rb