parse-stack-next 5.8.0 → 5.8.2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
@@ -84,12 +84,34 @@ module Parse
84
84
  end
85
85
  end
86
86
 
87
+ # Check that a webhook endpoint is an http(s) URL (scheme compared
88
+ # case-insensitively, surrounding whitespace ignored) and return it
89
+ # stripped. Plain http:// to a host that is not this machine is
90
+ # allowed but warns: Parse Server sends the webhook key on every call,
91
+ # so it would travel in cleartext.
92
+ # @param endpoint [String]
93
+ # @return [String]
94
+ # @raise [ArgumentError]
95
+ # @!visibility private
96
+ def validate_hooks_endpoint!(endpoint)
97
+ url = endpoint.to_s.strip
98
+ scheme = Parse::Client.url_scheme(url)
99
+ host = Parse::Client.url_host(url)
100
+ unless %w[http https].include?(scheme) && host.present? && url.match?(%r{\Ahttps?://}i)
101
+ raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}'"
102
+ end
103
+ if scheme == "http" && !Parse::Client.loopback_host?(host)
104
+ warn "[Parse::Webhooks] SECURITY WARNING: webhook endpoint #{url} uses http://. " \
105
+ "Parse Server sends the webhook key with every call, so it travels in " \
106
+ "cleartext. Use an https:// endpoint."
107
+ end
108
+ url
109
+ end
110
+
87
111
  # Registers all webhook functions registered with Parse::Stack with Parse server.
88
112
  # @param endpoint [String] a https url that points to the webhook server.
89
113
  def register_functions!(endpoint)
90
- unless endpoint.present? && (endpoint.starts_with?("http://") || endpoint.starts_with?("https://"))
91
- raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}''"
92
- end
114
+ endpoint = validate_hooks_endpoint!(endpoint)
93
115
  assert_webhook_url_safe!(endpoint)
94
116
  endpoint += "/" unless endpoint.ends_with?("/")
95
117
  functionsMap = {}
@@ -114,9 +136,7 @@ module Parse
114
136
  # @param endpoint [String] a https url that points to the webhook server.
115
137
  # @param include_wildcard [Boolean] Allow wildcard registrations
116
138
  def register_triggers!(endpoint, include_wildcard: false)
117
- unless endpoint.present? && (endpoint.starts_with?("http://") || endpoint.starts_with?("https://"))
118
- raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}''"
119
- end
139
+ endpoint = validate_hooks_endpoint!(endpoint)
120
140
  assert_webhook_url_safe!(endpoint)
121
141
  endpoint += "/" unless endpoint.ends_with?("/")
122
142
  all_triggers = Parse::API::Hooks::TRIGGER_NAMES_LOCAL
@@ -124,16 +124,26 @@ module Parse
124
124
  # works with any Rack request. Replay dedup applies only when
125
125
  # `request_id` or an `X-Parse-Webhook-Nonce` header is present.
126
126
  def verify!(env, body_str, request_id)
127
+ check(env, body_str, request_id).first
128
+ end
129
+
130
+ # @!visibility private
131
+ # Same checks as {.verify!}, also reporting whether a signature was
132
+ # verified. The signing secret is read once, so the answer always
133
+ # matches the check that ran.
134
+ # @return [Array(String, Boolean)] the error message (nil when the
135
+ # request passes) and true when it carried a valid signature.
136
+ def check(env, body_str, request_id)
127
137
  secret = signing_secret
128
138
  signed_key = nil
129
139
  if secret && !secret.empty?
130
140
  ts_header = env[HEADER_TIMESTAMP].to_s
131
141
  sig_header = env[HEADER_SIGNATURE].to_s
132
- return "Missing webhook signature." if ts_header.empty? || sig_header.empty?
133
- return "Invalid webhook timestamp." unless ts_header =~ /\A-?\d{1,12}\z/
142
+ return ["Missing webhook signature.", false] if ts_header.empty? || sig_header.empty?
143
+ return ["Invalid webhook timestamp.", false] unless ts_header =~ /\A-?\d{1,12}\z/
134
144
  ts = ts_header.to_i
135
145
  skew = (Time.now.to_i - ts).abs
136
- return "Stale webhook timestamp." if skew > signing_max_skew_seconds
146
+ return ["Stale webhook timestamp.", false] if skew > signing_max_skew_seconds
137
147
  # A sender that signs a per-delivery nonce (`ts.nonce.body`) gets a
138
148
  # distinct signature for every delivery, so identical bodies sent
139
149
  # in the same second are not mistaken for replays. The original
@@ -145,7 +155,7 @@ module Parse
145
155
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, material)
146
156
  ActiveSupport::SecurityUtils.secure_compare(expected, sig_header)
147
157
  end
148
- return "Invalid webhook signature." unless matched
158
+ return ["Invalid webhook signature.", false] unless matched
149
159
  # A signed delivery is deduplicated on its signature, which covers
150
160
  # the timestamp and body and cannot be changed without the secret.
151
161
  # Keying it on the unsigned nonce would let a captured request be
@@ -157,23 +167,23 @@ module Parse
157
167
  if signed_key
158
168
  window = [replay_window_seconds, signing_max_skew_seconds * 2].max
159
169
  digest = Digest::SHA256.hexdigest(signed_key)
160
- return "Webhook replay detected." if cache.seen?(digest, window)
170
+ return ["Webhook replay detected.", false] if cache.seen?(digest, window)
161
171
  cache.record(digest, replay_cache_size)
162
- return nil
172
+ return [nil, true]
163
173
  end
164
174
 
165
175
  # Dedup only when the delivery carries a per-delivery identifier.
166
176
  # Keying on the body alone rejects legitimate identical requests.
167
177
  nonce = request_id.to_s.strip
168
178
  nonce = env[HEADER_NONCE].to_s.strip if nonce.empty?
169
- return nil if nonce.empty?
179
+ return [nil, false] if nonce.empty?
170
180
 
171
181
  digest = Digest::SHA256.hexdigest("#{nonce}\x1f#{body_str}")
172
182
  if cache.seen?(digest, replay_window_seconds)
173
- return "Webhook replay detected."
183
+ return ["Webhook replay detected.", false]
174
184
  end
175
185
  cache.record(digest, replay_cache_size)
176
- nil
186
+ [nil, false]
177
187
  end
178
188
  end
179
189
 
@@ -477,7 +477,10 @@ module Parse
477
477
  payload&.raw&.dig("headers", "X-Parse-Request-Id")
478
478
  ruby_initiated = request_id&.start_with?("_RB_") || false
479
479
  payload.instance_variable_set(:@ruby_initiated, ruby_initiated)
480
- trusted_ruby_initiated = ruby_initiated && (payload.master? == true)
480
+ # `master?` is the authenticated claim: on unauthenticated ingress
481
+ # it is false, so a forged `_RB_` id plus `"master": true` cannot
482
+ # suppress model callbacks.
483
+ trusted_ruby_initiated = ruby_initiated && payload.master?
481
484
  else
482
485
  trusted_ruby_initiated = false
483
486
  end
@@ -501,6 +504,8 @@ module Parse
501
504
  master: payload.master? || false,
502
505
  is_new: payload.original.blank?,
503
506
  )
507
+ # A guard revert is not the handler assigning the ACL.
508
+ pre_obj.reset_webhook_handler_acl_assigned! if pre_obj.respond_to?(:reset_webhook_handler_acl_assigned!)
504
509
  end
505
510
  end
506
511
  end
@@ -529,6 +534,7 @@ module Parse
529
534
  # ran ActiveModel before_save callbacks locally. A client-spoofed
530
535
  # `_RB_` without master falls through and runs them here.
531
536
  unless trusted_ruby_initiated
537
+ adopt_request_user_as_acl_owner!(payload, result)
532
538
  before_save_result = result.run_before_save_callbacks
533
539
  # If a before_save callback halted the chain (returned false), reject the save.
534
540
  if before_save_result == false
@@ -670,7 +676,7 @@ module Parse
670
676
  # matched route, so read that stamped value rather than recomputing via
671
677
  # `ruby_initiated?` -- whose `||=` memoization re-derives on a stamped
672
678
  # `false` and could disagree with call_route's header lookup.
673
- return if payload.ruby_initiated? && payload.master? == true
679
+ return if payload.ruby_initiated? && payload.master?
674
680
 
675
681
  # By the time afterSave fires the object is ALREADY persisted in Parse
676
682
  # Server, and Parse Server discards the afterSave response body entirely
@@ -733,7 +739,7 @@ module Parse
733
739
  obj = payload.parse_object
734
740
  return unless obj.is_a?(Parse::Object)
735
741
  return unless route_registered?(:after_delete, payload.parse_class)
736
- return if payload.ruby_initiated? && payload.master? == true
742
+ return if payload.ruby_initiated? && payload.master?
737
743
  obj.run_after_delete_callbacks
738
744
  nil
739
745
  rescue => e
@@ -779,18 +785,23 @@ module Parse
779
785
  #
780
786
  # Fields are compared against a fresh build of the payload, so only the
781
787
  # 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.
788
+ # A field the handler rewrites is written as an absolute value. An
789
+ # operator on a dotted sub-key (`"meta.count"`) reaches the webhook only
790
+ # as its resulting sub-document, so on an update a changed sub-document
791
+ # is written back as dotted keys for just the sub-keys that differ from
792
+ # the stored one. A concurrent write to another sub-key survives; the
793
+ # changed sub-key itself is written as its resulting value. The split
794
+ # is one level deep (a changed sub-key is written whole at
795
+ # `field.sub`), and a field whose writes would carry a typed value is
796
+ # written whole.
787
797
  #
788
798
  # @param payload [Parse::Webhooks::Payload] the beforeSave payload.
789
799
  # @param obj [Parse::Object, nil] the handler's object (nil when none).
790
800
  # @param overrides [Hash, nil] field values a handler returned as a Hash.
791
801
  # @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.
802
+ # object's dirty fields the client did not send (declared defaults and
803
+ # the ACL the class policy resolves), matching what an SDK-side create
804
+ # sends.
794
805
  # @return [Hash, nil] the reply object, or nil for "unchanged".
795
806
  def before_save_reply(payload, obj, overrides: nil, include_create_defaults: false)
796
807
  return nil unless payload && payload.object?
@@ -806,27 +817,238 @@ module Parse
806
817
  if include_create_defaults && raw_original.nil?
807
818
  client_keys = raw_object.keys.map(&:to_s)
808
819
  dirty = obj.changed.map(&:to_sym) & snapshot_fields(obj)
820
+ # This includes the ACL. A create with no `ACL` key is stored by
821
+ # Parse Server as public read and write, so a class with an ACL
822
+ # policy always replies with the ACL that policy resolved (owned
823
+ # by the requesting user where the policy names an owner; see
824
+ # {adopt_request_user_as_acl_owner!}) or the ACL the handler set.
825
+ # An ACL the client sent is kept as sent.
809
826
  wire_values(obj, dirty).each do |remote, value|
810
827
  next if client_keys.include?(remote) || changes.key?(remote)
811
828
  changes[remote] = value
812
829
  end
813
830
  end
831
+ # An ACL the handler assigned on a create is written even when it
832
+ # equals the default stamp or the client's ACL, whatever the handler
833
+ # returned (the object, `true`, `nil`, or a Hash, whose own `ACL`
834
+ # still wins below). Diffing alone misses `obj.acl = Parse::ACL.new`
835
+ # under a `{}` default, and a create with no `ACL` key is stored
836
+ # public read and write.
837
+ if handler_assigned_acl?(obj) && !changes.key?("ACL")
838
+ changes.merge!(wire_values(obj, [:acl]).slice("ACL"))
839
+ end
814
840
  end
815
841
  overrides = overrides.as_json if overrides.is_a?(Hash)
816
842
  return nil if changes.empty? && drops.empty? && overrides.blank?
817
843
 
818
844
  reply = client_write_data(raw_object, raw_original)
845
+ overrides = overrides.present? ? remote_override_keys(payload, overrides) : {}
846
+ # A field the handler wrote or dropped replaces the client's dotted
847
+ # sub-key writes for it; MongoDB refuses `meta` and `meta.x` together.
848
+ (drops + changes.keys + overrides.keys).each do |remote|
849
+ reply.delete_if { |key, _| key.start_with?("#{remote}.") }
850
+ end
819
851
  drops.each { |remote| reply.delete(remote) }
820
852
  reply.merge!(changes)
821
- reply.merge!(overrides.transform_keys(&:to_s)) if overrides.present?
853
+ reply.merge!(overrides)
854
+ fold_dotted_overrides!(reply, overrides.keys, raw_object: raw_object, create: raw_original.nil?)
855
+ reply
856
+ end
857
+
858
+ # A handler's Hash override keyed by Ruby names (`{acl: ...}`,
859
+ # `{author_name: ...}`) is mapped to the remote field names the reply
860
+ # uses, so it replaces the matching field instead of sitting beside it.
861
+ # A dotted key maps its first segment.
862
+ # @!visibility private
863
+ def remote_override_keys(payload, overrides)
864
+ klass = begin
865
+ name = payload.parse_class
866
+ name.present? ? Parse::Object.find_class(name) : nil
867
+ rescue StandardError
868
+ nil
869
+ end
870
+ map = klass.respond_to?(:field_map) ? klass.field_map : {}
871
+ overrides.each_with_object({}) do |(key, value), out|
872
+ key = key.to_s
873
+ head, rest = key.split(".", 2)
874
+ remote = map[head.to_sym]
875
+ head = remote.to_s if remote
876
+ out[rest ? "#{head}.#{rest}" : head] = value
877
+ end
878
+ end
879
+
880
+ # A handler's Hash override may name a sub-key (`"meta.y"`) of a field
881
+ # the reply writes whole (a create, or a field written whole on an
882
+ # update). MongoDB refuses `meta` and `meta.y` in one update, so the
883
+ # sub-key write is folded into the whole value. An override deeper
884
+ # than one level (`"meta.count.value"`) is folded into a `field.sub`
885
+ # write seeded from the pending object, because Parse Server rebuilds
886
+ # the afterSave object from a dotted key only one level deep. On a
887
+ # create every dotted override folds into its whole field.
888
+ #
889
+ # When the value it folds into is not a plain sub-document, a client's
890
+ # value is replaced by the handler's sub-key, and two of the handler's
891
+ # own overrides that conflict (`"meta.a" => 5` with `"meta.a.b" => 6`)
892
+ # raise {ResponseError}.
893
+ # @!visibility private
894
+ def fold_dotted_overrides!(reply, override_keys, raw_object: {}, create: false)
895
+ handler_keys = override_keys.to_set
896
+ override_keys.sort_by { |k| k.count(".") }.each do |key|
897
+ next unless key.include?(".") && reply.key?(key)
898
+ segments = key.split(".")
899
+ # The nearest ancestor path the reply writes whole, if any.
900
+ parent = (1...segments.length).map { |n| segments.first(n).join(".") }.reverse.find { |p| reply.key?(p) }
901
+ unless parent
902
+ next if !create && segments.length <= 2
903
+ parent = create ? segments.first : segments.first(2).join(".")
904
+ seed = parent.split(".").reduce(raw_object) { |cur, seg| cur.is_a?(Hash) ? cur[seg] : nil }
905
+ reply[parent] = plain_sub_document?(seed) ? seed.deep_dup : {}
906
+ end
907
+ value = reply.delete(key)
908
+ whole = reply[parent]
909
+ if plain_sub_document?(whole)
910
+ whole = whole.deep_dup
911
+ elsif handler_keys.include?(parent)
912
+ raise Parse::Webhooks::ResponseError,
913
+ "before_save reply: #{key} conflicts with #{parent} in the handler's reply"
914
+ else
915
+ whole = {}
916
+ end
917
+ *dirs, leaf = segments.drop(parent.count(".") + 1)
918
+ node = dirs.reduce(whole) do |cur, dir|
919
+ cur[dir] = {} unless plain_sub_document?(cur[dir])
920
+ cur[dir]
921
+ end
922
+ if value.is_a?(Hash) && value.key?("__op")
923
+ # A whole write stores its value as data, so an operator folded
924
+ # into it is applied here rather than written as a Hash.
925
+ result = fold_override_op(key, node[leaf], value)
926
+ if result.equal?(FOLD_DELETE)
927
+ node.delete(leaf)
928
+ else
929
+ node[leaf] = result
930
+ end
931
+ else
932
+ node[leaf] = value
933
+ end
934
+ reply[parent] = whole
935
+ end
822
936
  reply
823
937
  end
824
938
 
939
+ # @!visibility private
940
+ FOLD_DELETE = Object.new.freeze
941
+
942
+ # Apply a handler's sub-key operator to the value it replaces inside a
943
+ # sub-document the reply writes whole, with Parse Server's semantics
944
+ # for that operator. Raises {ResponseError} (an `{error}` reply, so
945
+ # Parse Server refuses the save) for an operator that cannot be applied
946
+ # to a plain value, rather than storing the operator Hash as data.
947
+ # @param key [String] the dotted override key, for the error message.
948
+ # @param current [Object] the value at that path in the whole write.
949
+ # @param op [Hash] the operator Hash (`{"__op" => ...}`).
950
+ # @return [Object] the new value, or {FOLD_DELETE} to remove the key.
951
+ # @!visibility private
952
+ def fold_override_op(key, current, op)
953
+ name = op["__op"]
954
+ case name
955
+ when "Delete"
956
+ FOLD_DELETE
957
+ when "Increment"
958
+ amount = op["amount"]
959
+ unless amount.is_a?(Numeric) && (current.nil? || current.is_a?(Numeric))
960
+ raise Parse::Webhooks::ResponseError,
961
+ "before_save reply: cannot apply Increment to #{key} (#{current.class})"
962
+ end
963
+ (current || 0) + amount
964
+ when "Add", "AddUnique", "Remove"
965
+ objects = op["objects"]
966
+ unless objects.is_a?(Array) && (current.nil? || current.is_a?(Array))
967
+ raise Parse::Webhooks::ResponseError,
968
+ "before_save reply: cannot apply #{name} to #{key} (#{current.class})"
969
+ end
970
+ base = current || []
971
+ case name
972
+ when "Add" then base + objects
973
+ when "AddUnique" then base + objects.reject { |o| base.include?(o) }.uniq
974
+ else base.reject { |o| objects.include?(o) }
975
+ end
976
+ else
977
+ raise Parse::Webhooks::ResponseError,
978
+ "before_save reply: operator #{name.inspect} on #{key} cannot be combined " \
979
+ "with a whole write of its parent field"
980
+ end
981
+ end
982
+
983
+ # @!visibility private
984
+ def handler_assigned_acl?(obj)
985
+ return true if obj.instance_variable_get(:@_webhook_reply_acl) == true
986
+ obj.respond_to?(:webhook_handler_acl_assigned?) && obj.webhook_handler_acl_assigned?
987
+ end
988
+
989
+ # On a client create, an owner-based ACL policy (`:owner_else_private`,
990
+ # the shipped default, `:owner_else_public`, `:owner_but_public_read`)
991
+ # resolves its owner the way an SDK create `as:` the requesting user
992
+ # does: when the declared owner field holds no value, the user who made
993
+ # the request owns the record. A request without a user (anonymous, or
994
+ # master key) gets the policy's fallback. Leaves the object alone when
995
+ # the handler or the client already set an ACL.
996
+ # @!visibility private
997
+ def adopt_request_user_as_acl_owner!(payload, obj)
998
+ return unless payload && payload.original.nil?
999
+ if handler_assigned_acl?(obj)
1000
+ # The handler's ACL is final: keep the save-time policy resolver
1001
+ # from replacing it, even when it equals the default stamp.
1002
+ obj.instance_variable_set(:@_acl_pristine, false)
1003
+ return
1004
+ end
1005
+ return unless obj.is_a?(Parse::Object) && obj.instance_variable_get(:@_acl_pristine)
1006
+ return if obj.instance_variable_get(:@_acl_owner_override)
1007
+ klass = obj.class
1008
+ return if klass.respond_to?(:builtin_acl_default_active?) && klass.builtin_acl_default_active?
1009
+ return unless klass.respond_to?(:acl_policy_setting) && klass.acl_policy_setting.to_s.start_with?("owner_")
1010
+ field = klass.acl_owner_field
1011
+ if field == :self
1012
+ adopt_self_owned_acl!(obj, klass)
1013
+ return
1014
+ end
1015
+ # A master-key request is not the user's own write.
1016
+ return if payload.master?
1017
+ user = payload.user
1018
+ return unless user.is_a?(Parse::User) && user.id.present?
1019
+ if field && obj.respond_to?(field)
1020
+ return if obj.send(field).present?
1021
+ end
1022
+ obj.instance_variable_set(:@_acl_owner_override, user)
1023
+ end
1024
+
1025
+ # A self-owned user (`acl_policy ..., owner: :self`) owns its own
1026
+ # record, never the requesting user. Its objectId is assigned by Parse
1027
+ # Server after beforeSave, and Parse Server adds the new user's own
1028
+ # read and write to the ACL of every `_User` create. So the reply
1029
+ # carries the policy's ACL without the owner entry (`{}`, or public
1030
+ # read under `:owner_but_public_read`), which Parse Server completes
1031
+ # with the user's own grant. The save-time resolver is skipped, since
1032
+ # it would pre-generate an objectId the server does not use.
1033
+ # @!visibility private
1034
+ def adopt_self_owned_acl!(obj, klass)
1035
+ acl = if klass.acl_policy_setting == :owner_but_public_read
1036
+ Parse::ACL.everyone(true, false)
1037
+ else
1038
+ Parse::ACL.new
1039
+ end
1040
+ klass.instance_method(:acl=).bind_call(obj, acl)
1041
+ obj.instance_variable_set(:@_acl_pristine, false)
1042
+ obj.instance_variable_set(:@_webhook_reply_acl, true)
1043
+ end
1044
+
825
1045
  # The client's write, rebuilt from a beforeSave payload. Parse Server
826
1046
  # serializes the pending object with `toJSON()`, which reports every
827
1047
  # pending top-level operator as its operator hash, so the operators
828
1048
  # 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.
1049
+ # stored one was not written by the client and is left out, and a
1050
+ # changed sub-document is split into dotted sub-key writes (see
1051
+ # {sub_document_write}).
830
1052
  #
831
1053
  # @param raw_object [Hash] the unscrubbed `object` hash.
832
1054
  # @param raw_original [Hash, nil] the unscrubbed `original` hash.
@@ -841,12 +1063,96 @@ module Parse
841
1063
  # Parse Server drops objectId from an update write itself.
842
1064
  next if key == Parse::Model::OBJECT_ID
843
1065
  next if raw_original.key?(key) && raw_original[key] == value
1066
+ dotted = sub_document_write(key, value, raw_original[key])
1067
+ if dotted
1068
+ data.merge!(dotted)
1069
+ next
1070
+ end
844
1071
  end
845
1072
  data[key] = value
846
1073
  end
847
1074
  data
848
1075
  end
849
1076
 
1077
+ # Keys never split into dotted sub-key writes: their values are
1078
+ # replaced as a whole by Parse Server.
1079
+ # @!visibility private
1080
+ BEFORE_SAVE_REPLY_WHOLE_KEYS = %w[ACL authData].freeze
1081
+
1082
+ # Dotted sub-key writes for a sub-document the client changed.
1083
+ #
1084
+ # Parse Server applies a client's `"meta.count"` operator to its pending
1085
+ # object and sends the webhook only the resulting `meta`, which matches
1086
+ # a whole-object write of the same value. Writing just the sub-keys that
1087
+ # differ from the stored sub-document gives the same result as either
1088
+ # client write, without overwriting sub-keys another request changed in
1089
+ # the meantime. The split is one level deep: a changed sub-key is
1090
+ # written whole at `field.sub`, nested objects included, because Parse
1091
+ # Server rebuilds the afterSave object from a non-operator dotted key
1092
+ # only one level deep (`"meta.count.value"` would set `meta.count` to
1093
+ # the leaf). A sub-key missing from the new value is written as a
1094
+ # `Delete` operator.
1095
+ #
1096
+ # Parse Server stores a dotted value as sent, without the type transform
1097
+ # a whole write gets, so a Date would land as a plain sub-document
1098
+ # instead of a BSON date. When any write would carry a typed value
1099
+ # (`{"__type": ...}`), the field is written whole instead.
1100
+ #
1101
+ # @param key [String] the top-level field name.
1102
+ # @param value [Object] the field's value in the pending object.
1103
+ # @param stored [Object] the field's stored value.
1104
+ # @return [Hash, nil] the dotted writes, or nil to write the field whole.
1105
+ # @!visibility private
1106
+ def sub_document_write(key, value, stored)
1107
+ return nil if key.start_with?("_") || BEFORE_SAVE_REPLY_WHOLE_KEYS.include?(key)
1108
+ return nil unless splittable_level?(value, stored)
1109
+ writes = {}
1110
+ diff_sub_document(key, value, stored, writes)
1111
+ return nil if writes.empty? || writes.values.any? { |v| typed_value?(v) }
1112
+ writes
1113
+ end
1114
+
1115
+ # Collect the one-level dotted writes that turn `stored` into `value`
1116
+ # under `path`.
1117
+ # @!visibility private
1118
+ def diff_sub_document(path, value, stored, writes)
1119
+ (value.keys | stored.keys).each do |sub|
1120
+ new_present = value.key?(sub)
1121
+ old_present = stored.key?(sub)
1122
+ next if new_present && old_present && value[sub] == stored[sub]
1123
+ sub_path = "#{path}.#{sub}"
1124
+ writes[sub_path] = new_present ? value[sub] : { "__op" => "Delete" }
1125
+ end
1126
+ end
1127
+
1128
+ # Whether a pair of values can be diffed key by key: both plain JSON
1129
+ # objects, with sub-keys that are usable as path segments.
1130
+ # @!visibility private
1131
+ def splittable_level?(value, stored)
1132
+ return false unless plain_sub_document?(value) && plain_sub_document?(stored)
1133
+ keys = value.keys | stored.keys
1134
+ return false if keys.empty?
1135
+ keys.none? { |k| k.to_s.empty? || k.to_s.include?(".") || k.to_s.start_with?("$") }
1136
+ end
1137
+
1138
+ # Whether a JSON value is, or contains, a Parse typed value
1139
+ # (`{"__type": ...}`: Date, Bytes, Pointer, File, GeoPoint, ...).
1140
+ # @!visibility private
1141
+ def typed_value?(value)
1142
+ case value
1143
+ when Hash then value.key?("__type") || value.values.any? { |v| typed_value?(v) }
1144
+ when Array then value.any? { |v| typed_value?(v) }
1145
+ else false
1146
+ end
1147
+ end
1148
+
1149
+ # A JSON object field value: a Hash that is not a Parse operator or a
1150
+ # typed value (Pointer, Date, File, GeoPoint, ...).
1151
+ # @!visibility private
1152
+ def plain_sub_document?(value)
1153
+ value.is_a?(Hash) && !value.key?("__op") && !value.key?("__type")
1154
+ end
1155
+
850
1156
  # Diff the handler's object against a fresh build of the same payload.
851
1157
  #
852
1158
  # Only fields that are dirty on either object can differ: a field the
@@ -1118,7 +1424,12 @@ module Parse
1118
1424
  request = Rack::Request.new env
1119
1425
  response = Rack::Response.new
1120
1426
 
1427
+ # Whether this request proved it came from Parse Server: the webhook
1428
+ # key matched, or (below) a configured signature verified. Without
1429
+ # either, the body's `master` flag is only a claim.
1430
+ authenticated = false
1121
1431
  if self.key.present?
1432
+ authenticated = true
1122
1433
  provided_key = request.env[HTTP_PARSE_WEBHOOK].to_s
1123
1434
  unless ActiveSupport::SecurityUtils.secure_compare(self.key, provided_key)
1124
1435
  puts "[Parse::Webhooks] Invalid Parse-Webhook Key received"
@@ -1163,7 +1474,7 @@ module Parse
1163
1474
  # require a fresh HMAC over the body. Done before JSON parsing so
1164
1475
  # a malformed payload can't bypass dedup, and before any handler
1165
1476
  # runs so side effects aren't repeated.
1166
- replay_error = ReplayProtection.verify!(
1477
+ replay_error, signature_verified = ReplayProtection.check(
1167
1478
  request.env,
1168
1479
  body_str,
1169
1480
  request.env["HTTP_X_PARSE_REQUEST_ID"]
@@ -1172,6 +1483,10 @@ module Parse
1172
1483
  response.write error(replay_error)
1173
1484
  return response.finish
1174
1485
  end
1486
+ # Uses the result of the check that just ran, not a second read of
1487
+ # the signing secret, so a secret changed mid-request cannot mark an
1488
+ # unverified request as authenticated.
1489
+ authenticated ||= signature_verified
1175
1490
 
1176
1491
  # Parse Server registers each trigger at
1177
1492
  # `<endpoint>/<triggerName>/<className>`. For beforeFind/afterFind the
@@ -1184,6 +1499,7 @@ module Parse
1184
1499
  webhook_class = Parse::Webhooks.trigger_class_from_path(request.path)
1185
1500
  begin
1186
1501
  payload = Parse::Webhooks::Payload.new(body_str, webhook_class)
1502
+ payload.authenticated = authenticated
1187
1503
  rescue => e
1188
1504
  warn "Invalid webhook payload format: #{Parse::TerminalSafe.sanitize_line(e.to_s)}"
1189
1505
  response.write error("Invalid payload format. Should be valid JSON.")
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.8.0
4
+ version: 5.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin