parse-stack-next 5.7.6 → 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 +830 -0
  3. data/README.md +14 -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 +181 -13
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +317 -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 +225 -8
  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
@@ -561,9 +561,9 @@ module Parse
561
561
 
562
562
  # @!visibility private
563
563
  # Returns `true` when `@object`/`@original` contain a className that
564
- # disagrees with the trigger's expected class. Used to skip building
565
- # a typed object when the payload was clearly forged or routed
566
- # incorrectly.
564
+ # disagrees with the trigger's expected class (the class from the
565
+ # webhook URL path). The Rack app refuses such a request before any
566
+ # handler runs, since the payload was forged or routed incorrectly.
567
567
  def payload_class_mismatch?
568
568
  expected = parse_class
569
569
  return false if expected.nil?
@@ -573,6 +573,44 @@ module Parse
573
573
  end
574
574
  end
575
575
 
576
+ # @!visibility private
577
+ # The memoized {#parse_object} if one was built during this request
578
+ # (by a handler or the field-guard step), else nil. Never builds one.
579
+ # @return [Parse::Object, nil]
580
+ def memoized_parse_object
581
+ defined?(@parse_object) ? @parse_object : nil
582
+ end
583
+
584
+ # @!visibility private
585
+ # A new, unmemoized build of the trigger object, as the client's write
586
+ # describes it (no handler edits, no field-guard reverts). The beforeSave
587
+ # reply diffs the handler's object against this.
588
+ # @return [Parse::Object, nil]
589
+ def unmemoized_parse_object
590
+ return nil unless object?
591
+ build_parse_object
592
+ rescue StandardError
593
+ # Comparison baseline only: an unbuildable payload (e.g. a class with
594
+ # no registered model) leaves every dirty field counted as changed.
595
+ nil
596
+ end
597
+
598
+ # @!visibility private
599
+ # The `object` hash exactly as Parse Server sent it, before credential
600
+ # and vector scrubbing. Used only to rebuild the client's write for a
601
+ # beforeSave reply; never logged or exposed through {#as_json}.
602
+ # @return [Hash, nil]
603
+ def raw_object
604
+ @raw.is_a?(Hash) ? @raw[:object] : nil
605
+ end
606
+
607
+ # @!visibility private
608
+ # The `original` hash exactly as Parse Server sent it (see {#raw_object}).
609
+ # @return [Hash, nil]
610
+ def raw_original
611
+ @raw.is_a?(Hash) ? @raw[:original] : nil
612
+ end
613
+
576
614
  # Force a fresh build, discarding any memoized parse_object. Used by the
577
615
  # webhook framework after mutating @object / @update so a subsequent
578
616
  # parse_object call picks up the modified payload state.
@@ -678,11 +716,20 @@ module Parse
678
716
  # a specific message. When used inside of a registered cloud code webhook
679
717
  # function or trigger, will halt processing and return the proper error response
680
718
  # code back to the Parse server.
719
+ # @example
720
+ # error!("title is required")
721
+ # error!("duplicate slug", code: 137) # see note on code
681
722
  # @param msg [String] the error message to send back.
723
+ # @param code [Integer, nil] an optional Parse error code, written to the
724
+ # error body as `"code"` and available as
725
+ # {Parse::Webhooks::ResponseError#code}. Parse Server's HTTP webhook
726
+ # adapter (as of 9.10) still reports webhook errors to the client as
727
+ # code 141; the code reaches in-process callers such as
728
+ # {Parse::Webhooks.run_function}.
682
729
  # @raise Parse::Webhooks::ResponseError
683
730
  # @return [Parse::Webhooks::ResponseError] the raised exception
684
- def error!(msg = "")
685
- raise Parse::Webhooks::ResponseError, msg
731
+ def error!(msg = "", code: nil)
732
+ raise Parse::Webhooks::ResponseError.new(msg, code: code)
686
733
  end
687
734
 
688
735
  # Register a block to run **after** this webhook's response has been sent
@@ -729,10 +776,50 @@ module Parse
729
776
  @deferred_callbacks ||= []
730
777
  end
731
778
 
779
+ # The query of a beforeFind (or LiveQuery beforeSubscribe) trigger as a
780
+ # {Parse::Query}.
781
+ #
782
+ # Parse Server sends the query in its REST JSON form: the constraints sit
783
+ # under `where`, next to `limit`, `skip`, `order` (a comma-separated
784
+ # string, `-` for descending), `keys` and `include`. Only the `where`
785
+ # entries become constraints, and they are added verbatim, so a field
786
+ # that happens to be named like a query option (`limit`, `order`,
787
+ # `key`) is still a field constraint. A top-level `$or` is kept; other
788
+ # top-level operators are left out of the returned query (the raw form
789
+ # is always available as {#query}).
732
790
  # @return [Parse::Query] the Parse query for a beforeFind trigger.
733
791
  def parse_query
734
792
  return nil unless parse_class.present? && @query.is_a?(Hash)
735
- Parse::Query.new parse_class, @query
793
+ spec = @query.with_indifferent_access
794
+ q = Parse::Query.new(parse_class)
795
+ where = spec[:where]
796
+ if where.is_a?(Hash)
797
+ constraints = where.filter_map do |field, value|
798
+ field = field.to_s
799
+ if field == "$or"
800
+ Parse::Constraint::CompoundQueryConstraint.new(:or, Array.wrap(value))
801
+ elsif field.start_with?("$")
802
+ # Other top-level operators ($and, $nor, $relatedTo) have no
803
+ # field-constraint form here; they stay visible in #query.
804
+ nil
805
+ else
806
+ Parse::Constraint.create(field, value)
807
+ end
808
+ end
809
+ q.add_constraints(constraints)
810
+ end
811
+ q.limit(spec[:limit].to_i) if spec[:limit].is_a?(Numeric) && spec[:limit].to_i >= 0
812
+ q.skip(spec[:skip].to_i) if spec[:skip].is_a?(Numeric) && spec[:skip].to_i > 0
813
+ split = ->(v) { v.is_a?(Array) ? v.map(&:to_s) : v.to_s.split(",").map(&:strip).reject(&:empty?) }
814
+ order = split.call(spec[:order]).map do |f|
815
+ f.start_with?("-") ? Parse::Order.new(f[1..].to_sym, :desc) : Parse::Order.new(f.to_sym, :asc)
816
+ end
817
+ q.order(order) if order.any?
818
+ keys = split.call(spec[:keys])
819
+ q.keys(keys) if keys.any?
820
+ includes = split.call(spec[:include])
821
+ q.includes(includes) if includes.any?
822
+ q
736
823
  end
737
824
 
738
825
  # Returns true if this webhook was triggered by a Ruby Parse Stack request.
@@ -19,13 +19,19 @@ module Parse
19
19
  #
20
20
  # This module adds two layers on top of the existing static-key check:
21
21
  #
22
- # 1. **Always-on body+request-id dedup.** A bounded LRU records a
23
- # SHA-256 of `(request_id || "")` joined with the request body. A
24
- # duplicate seen within `replay_window_seconds` is rejected with
25
- # `"Webhook replay detected."`. Cooperation with Parse Server is not
26
- # required; this protects against in-window replays only, but those
27
- # are the cheapest attack to mount (proxy retries, captured fast
28
- # loops, retransmits).
22
+ # 1. **Nonce-keyed dedup.** When a delivery carries a per-delivery
23
+ # identifier (an `X-Parse-Request-Id` or `X-Parse-Webhook-Nonce`
24
+ # header), a bounded LRU records a SHA-256 of that identifier joined
25
+ # with the request body. A duplicate seen within
26
+ # `replay_window_seconds` is rejected with
27
+ # `"Webhook replay detected."`.
28
+ #
29
+ # Parse Server sends neither header on its webhook deliveries, so for
30
+ # a stock deployment this layer is inactive. It deliberately does NOT
31
+ # fall back to the body alone: two legitimate calls with identical
32
+ # bodies (the same function called twice with the same params, the
33
+ # same find run twice) are indistinguishable from a replay by body,
34
+ # and rejecting them breaks correct traffic.
29
35
  #
30
36
  # 2. **Opt-in HMAC freshness verification.** When a `signing_secret` is
31
37
  # configured (programmatically or via
@@ -37,20 +43,22 @@ module Parse
37
43
  # bytes `"#{timestamp}.#{body}"` keyed with the signing secret.
38
44
  #
39
45
  # Requests outside `signing_max_skew_seconds` (default 300) or with
40
- # an invalid signature are rejected. Once enabled, this gives full
41
- # binding between the body and the time of delivery and closes the
42
- # replay window beyond the freshness skew.
46
+ # an invalid signature are rejected. This bounds a replay to the skew
47
+ # window; adding an `X-Parse-Webhook-Nonce` header as well closes that
48
+ # window through layer 1.
43
49
  #
44
- # Operators wanting layer 2 must arrange for Parse Server to add these
45
- # headers. Parse Server does not natively sign webhook deliveries, so
46
- # this is typically done with a thin Cloud Code wrapper or an egress
47
- # proxy. Until enabled, layer 1 still applies.
50
+ # Operators wanting either layer must arrange for these headers to be
51
+ # added. Parse Server does not natively sign webhook deliveries or tag
52
+ # them with a nonce, so this is typically done with a thin Cloud Code
53
+ # wrapper or an egress proxy.
48
54
  module ReplayProtection
49
55
  # @!visibility private
50
56
  HEADER_TIMESTAMP = "HTTP_X_PARSE_WEBHOOK_TIMESTAMP"
51
57
  # @!visibility private
52
58
  HEADER_SIGNATURE = "HTTP_X_PARSE_WEBHOOK_SIGNATURE"
53
59
  # @!visibility private
60
+ HEADER_NONCE = "HTTP_X_PARSE_WEBHOOK_NONCE"
61
+ # @!visibility private
54
62
  DEFAULT_REPLAY_WINDOW = 300
55
63
  # @!visibility private
56
64
  DEFAULT_REPLAY_CACHE_SIZE = 10_000
@@ -76,7 +84,7 @@ module Parse
76
84
  @signing_max_skew_seconds || DEFAULT_MAX_SKEW
77
85
  end
78
86
 
79
- # How long a `(request_id, body)` digest stays in the dedup cache.
87
+ # How long a `(nonce, body)` digest stays in the dedup cache.
80
88
  # Duplicates seen within this window are rejected.
81
89
  def replay_window_seconds
82
90
  @replay_window_seconds || DEFAULT_REPLAY_WINDOW
@@ -113,9 +121,11 @@ module Parse
113
121
  # Returns nil when the request passes both replay and signature
114
122
  # checks; otherwise returns a short error string suitable for the
115
123
  # webhook error response. The headers come from `env` so this
116
- # works with any Rack request.
124
+ # works with any Rack request. Replay dedup applies only when
125
+ # `request_id` or an `X-Parse-Webhook-Nonce` header is present.
117
126
  def verify!(env, body_str, request_id)
118
127
  secret = signing_secret
128
+ signed_key = nil
119
129
  if secret && !secret.empty?
120
130
  ts_header = env[HEADER_TIMESTAMP].to_s
121
131
  sig_header = env[HEADER_SIGNATURE].to_s
@@ -124,13 +134,41 @@ module Parse
124
134
  ts = ts_header.to_i
125
135
  skew = (Time.now.to_i - ts).abs
126
136
  return "Stale webhook timestamp." if skew > signing_max_skew_seconds
127
- expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{body_str}")
128
- unless ActiveSupport::SecurityUtils.secure_compare(expected, sig_header)
129
- return "Invalid webhook signature."
137
+ # A sender that signs a per-delivery nonce (`ts.nonce.body`) gets a
138
+ # distinct signature for every delivery, so identical bodies sent
139
+ # in the same second are not mistaken for replays. The original
140
+ # `ts.body` form is still accepted.
141
+ delivery_nonce = env[HEADER_NONCE].to_s.strip
142
+ candidates = ["#{ts}.#{body_str}"]
143
+ candidates.unshift("#{ts}.#{delivery_nonce}.#{body_str}") unless delivery_nonce.empty?
144
+ matched = candidates.any? do |material|
145
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, material)
146
+ ActiveSupport::SecurityUtils.secure_compare(expected, sig_header)
130
147
  end
148
+ return "Invalid webhook signature." unless matched
149
+ # A signed delivery is deduplicated on its signature, which covers
150
+ # the timestamp and body and cannot be changed without the secret.
151
+ # Keying it on the unsigned nonce would let a captured request be
152
+ # replayed within the timestamp window by altering or dropping
153
+ # that header.
154
+ signed_key = "sig\x1f#{sig_header}"
131
155
  end
132
156
 
133
- digest = Digest::SHA256.hexdigest("#{request_id}\x1f#{body_str}")
157
+ if signed_key
158
+ window = [replay_window_seconds, signing_max_skew_seconds * 2].max
159
+ digest = Digest::SHA256.hexdigest(signed_key)
160
+ return "Webhook replay detected." if cache.seen?(digest, window)
161
+ cache.record(digest, replay_cache_size)
162
+ return nil
163
+ end
164
+
165
+ # Dedup only when the delivery carries a per-delivery identifier.
166
+ # Keying on the body alone rejects legitimate identical requests.
167
+ nonce = request_id.to_s.strip
168
+ nonce = env[HEADER_NONCE].to_s.strip if nonce.empty?
169
+ return nil if nonce.empty?
170
+
171
+ digest = Digest::SHA256.hexdigest("#{nonce}\x1f#{body_str}")
134
172
  if cache.seen?(digest, replay_window_seconds)
135
173
  return "Webhook replay detected."
136
174
  end