parse-stack-next 5.7.6 → 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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  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/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -557,13 +557,56 @@ module Parse
557
557
  # object from @object/@original).
558
558
  return @parse_object if defined?(@parse_object) && !@parse_object.nil?
559
559
  @parse_object = build_parse_object
560
+ # On a beforeSave create, note when the handler assigns an ACL. The
561
+ # model's dirty tracking ignores an assignment equal to the default
562
+ # stamp (`obj.acl = Parse::ACL.new` under a `{}` default), and the
563
+ # reply must still treat it as the handler's explicit choice.
564
+ if before_save? && @original.blank? && @parse_object.is_a?(Parse::Object)
565
+ @parse_object.extend(HandlerAclAssignment)
566
+ end
567
+ @parse_object
568
+ end
569
+
570
+ # Marks an object whose handler assigned its ACL. Used only on the
571
+ # object a beforeSave create handler receives.
572
+ # @!visibility private
573
+ module HandlerAclAssignment
574
+ def acl=(value)
575
+ @_webhook_handler_acl_assigned = true
576
+ super
577
+ end
578
+
579
+ # Mass assignment (`attributes=`, `apply_attributes!`) sets the ACL
580
+ # through these setters rather than `acl=`: `acl` by its Ruby name,
581
+ # `ACL` (an alias bound at class definition) by its remote name.
582
+ def acl_set_attribute!(*args, **kwargs, &block)
583
+ @_webhook_handler_acl_assigned = true
584
+ super
585
+ end
586
+
587
+ def ACL_set_attribute!(*args, **kwargs, &block)
588
+ @_webhook_handler_acl_assigned = true
589
+ super
590
+ end
591
+
592
+ # Forget assignments made before the handler ran (the field-guard
593
+ # pre-step reverts a client ACL through `acl=`).
594
+ # @!visibility private
595
+ def reset_webhook_handler_acl_assigned!
596
+ @_webhook_handler_acl_assigned = false
597
+ end
598
+
599
+ # @return [Boolean] whether the handler assigned the ACL.
600
+ def webhook_handler_acl_assigned?
601
+ @_webhook_handler_acl_assigned == true
602
+ end
560
603
  end
561
604
 
562
605
  # @!visibility private
563
606
  # 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.
607
+ # disagrees with the trigger's expected class (the class from the
608
+ # webhook URL path). The Rack app refuses such a request before any
609
+ # handler runs, since the payload was forged or routed incorrectly.
567
610
  def payload_class_mismatch?
568
611
  expected = parse_class
569
612
  return false if expected.nil?
@@ -573,6 +616,44 @@ module Parse
573
616
  end
574
617
  end
575
618
 
619
+ # @!visibility private
620
+ # The memoized {#parse_object} if one was built during this request
621
+ # (by a handler or the field-guard step), else nil. Never builds one.
622
+ # @return [Parse::Object, nil]
623
+ def memoized_parse_object
624
+ defined?(@parse_object) ? @parse_object : nil
625
+ end
626
+
627
+ # @!visibility private
628
+ # A new, unmemoized build of the trigger object, as the client's write
629
+ # describes it (no handler edits, no field-guard reverts). The beforeSave
630
+ # reply diffs the handler's object against this.
631
+ # @return [Parse::Object, nil]
632
+ def unmemoized_parse_object
633
+ return nil unless object?
634
+ build_parse_object
635
+ rescue StandardError
636
+ # Comparison baseline only: an unbuildable payload (e.g. a class with
637
+ # no registered model) leaves every dirty field counted as changed.
638
+ nil
639
+ end
640
+
641
+ # @!visibility private
642
+ # The `object` hash exactly as Parse Server sent it, before credential
643
+ # and vector scrubbing. Used only to rebuild the client's write for a
644
+ # beforeSave reply; never logged or exposed through {#as_json}.
645
+ # @return [Hash, nil]
646
+ def raw_object
647
+ @raw.is_a?(Hash) ? @raw[:object] : nil
648
+ end
649
+
650
+ # @!visibility private
651
+ # The `original` hash exactly as Parse Server sent it (see {#raw_object}).
652
+ # @return [Hash, nil]
653
+ def raw_original
654
+ @raw.is_a?(Hash) ? @raw[:original] : nil
655
+ end
656
+
576
657
  # Force a fresh build, discarding any memoized parse_object. Used by the
577
658
  # webhook framework after mutating @object / @update so a subsequent
578
659
  # parse_object call picks up the modified payload state.
@@ -678,11 +759,20 @@ module Parse
678
759
  # a specific message. When used inside of a registered cloud code webhook
679
760
  # function or trigger, will halt processing and return the proper error response
680
761
  # code back to the Parse server.
762
+ # @example
763
+ # error!("title is required")
764
+ # error!("duplicate slug", code: 137) # see note on code
681
765
  # @param msg [String] the error message to send back.
766
+ # @param code [Integer, nil] an optional Parse error code, written to the
767
+ # error body as `"code"` and available as
768
+ # {Parse::Webhooks::ResponseError#code}. Parse Server's HTTP webhook
769
+ # adapter (as of 9.10) still reports webhook errors to the client as
770
+ # code 141; the code reaches in-process callers such as
771
+ # {Parse::Webhooks.run_function}.
682
772
  # @raise Parse::Webhooks::ResponseError
683
773
  # @return [Parse::Webhooks::ResponseError] the raised exception
684
- def error!(msg = "")
685
- raise Parse::Webhooks::ResponseError, msg
774
+ def error!(msg = "", code: nil)
775
+ raise Parse::Webhooks::ResponseError.new(msg, code: code)
686
776
  end
687
777
 
688
778
  # Register a block to run **after** this webhook's response has been sent
@@ -729,10 +819,50 @@ module Parse
729
819
  @deferred_callbacks ||= []
730
820
  end
731
821
 
822
+ # The query of a beforeFind (or LiveQuery beforeSubscribe) trigger as a
823
+ # {Parse::Query}.
824
+ #
825
+ # Parse Server sends the query in its REST JSON form: the constraints sit
826
+ # under `where`, next to `limit`, `skip`, `order` (a comma-separated
827
+ # string, `-` for descending), `keys` and `include`. Only the `where`
828
+ # entries become constraints, and they are added verbatim, so a field
829
+ # that happens to be named like a query option (`limit`, `order`,
830
+ # `key`) is still a field constraint. A top-level `$or` is kept; other
831
+ # top-level operators are left out of the returned query (the raw form
832
+ # is always available as {#query}).
732
833
  # @return [Parse::Query] the Parse query for a beforeFind trigger.
733
834
  def parse_query
734
835
  return nil unless parse_class.present? && @query.is_a?(Hash)
735
- Parse::Query.new parse_class, @query
836
+ spec = @query.with_indifferent_access
837
+ q = Parse::Query.new(parse_class)
838
+ where = spec[:where]
839
+ if where.is_a?(Hash)
840
+ constraints = where.filter_map do |field, value|
841
+ field = field.to_s
842
+ if field == "$or"
843
+ Parse::Constraint::CompoundQueryConstraint.new(:or, Array.wrap(value))
844
+ elsif field.start_with?("$")
845
+ # Other top-level operators ($and, $nor, $relatedTo) have no
846
+ # field-constraint form here; they stay visible in #query.
847
+ nil
848
+ else
849
+ Parse::Constraint.create(field, value)
850
+ end
851
+ end
852
+ q.add_constraints(constraints)
853
+ end
854
+ q.limit(spec[:limit].to_i) if spec[:limit].is_a?(Numeric) && spec[:limit].to_i >= 0
855
+ q.skip(spec[:skip].to_i) if spec[:skip].is_a?(Numeric) && spec[:skip].to_i > 0
856
+ split = ->(v) { v.is_a?(Array) ? v.map(&:to_s) : v.to_s.split(",").map(&:strip).reject(&:empty?) }
857
+ order = split.call(spec[:order]).map do |f|
858
+ f.start_with?("-") ? Parse::Order.new(f[1..].to_sym, :desc) : Parse::Order.new(f.to_sym, :asc)
859
+ end
860
+ q.order(order) if order.any?
861
+ keys = split.call(spec[:keys])
862
+ q.keys(keys) if keys.any?
863
+ includes = split.call(spec[:include])
864
+ q.includes(includes) if includes.any?
865
+ q
736
866
  end
737
867
 
738
868
  # 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