parse-stack-next 5.3.0 → 5.4.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -1
@@ -7,15 +7,52 @@ module Parse
7
7
  module Hooks
8
8
  # @!visibility private
9
9
  HOOKS_PREFIX = "hooks/"
10
- # The allowed set of Parse triggers.
11
- TRIGGER_NAMES = [:afterCreate, :afterDelete, :afterFind, :afterSave, :beforeDelete, :beforeFind, :beforeSave].freeze
10
+ # The allowed set of Parse webhook triggers. Mirrors Parse Server's
11
+ # `triggers.Types` so registration of the auth / LiveQuery / password-
12
+ # reset hooks is no longer pre-rejected by the SDK.
13
+ #
14
+ # NOTE: this allowlist gates *registration* only. The webhook router in
15
+ # {Parse::Webhooks} currently shapes payloads for the object triggers
16
+ # (before/after save/delete/find); the login / connect / subscribe /
17
+ # password-reset payloads carry a different shape (no `object`) and
18
+ # their first-class routing is a follow-up. `beforeConnect` is a
19
+ # connection-global trigger whose Parse-canonical className is the
20
+ # `@Connect` sentinel; file triggers use `@File`. Both are accepted by
21
+ # the trigger-className validator ({Parse::API::PathSegment.trigger_class_name!}).
22
+ TRIGGER_NAMES = [
23
+ :afterDelete, :afterFind, :afterSave,
24
+ :beforeDelete, :beforeFind, :beforeSave,
25
+ :beforeLogin, :afterLogin, :afterLogout, :beforePasswordResetRequest,
26
+ :beforeConnect, :beforeSubscribe, :afterEvent,
27
+ ].freeze
12
28
  # @!visibility private
13
- TRIGGER_NAMES_LOCAL = [:after_create, :after_delete, :after_find, :after_save, :before_delete, :before_find, :before_save].freeze
29
+ TRIGGER_NAMES_LOCAL = [
30
+ :after_delete, :after_find, :after_save,
31
+ :before_delete, :before_find, :before_save,
32
+ :before_login, :after_login, :after_logout, :before_password_reset_request,
33
+ :before_connect, :before_subscribe, :after_event,
34
+ ].freeze
35
+
36
+ # `beforeCreate` / `afterCreate` are NOT Parse Server trigger types —
37
+ # Parse Server rejects them ("invalid hook declaration"). They exist only
38
+ # as Parse-Stack ActiveModel callbacks (`before_create` / `after_create`),
39
+ # which the webhook router runs INSIDE the `beforeSave` / `afterSave`
40
+ # handler for new objects (gated on `original.nil?`). So there is nothing
41
+ # to register for them — register `beforeSave` / `afterSave` instead and
42
+ # the create callbacks fire within it.
14
43
  # @!visibility private
15
44
  def _verify_trigger(triggerName)
16
- triggerName = triggerName.to_s.camelize(:lower).to_sym
17
- raise ArgumentError, "Invalid trigger name #{triggerName}" unless TRIGGER_NAMES.include?(triggerName)
18
- triggerName
45
+ camel = triggerName.to_s.camelize(:lower).to_sym
46
+ if %i[beforeCreate afterCreate].include?(camel)
47
+ save = camel == :beforeCreate ? "beforeSave" : "afterSave"
48
+ callback = camel == :beforeCreate ? "before_create" : "after_create"
49
+ raise ArgumentError,
50
+ "Parse Server has no #{camel} webhook trigger. Register a " \
51
+ "#{save} webhook instead — Parse Stack runs your #{callback} " \
52
+ "ActiveModel callbacks within the #{save} handler for new objects."
53
+ end
54
+ raise ArgumentError, "Invalid trigger name #{camel}" unless TRIGGER_NAMES.include?(camel)
55
+ camel
19
56
  end
20
57
 
21
58
  # Fetch all defined cloud code functions.
@@ -74,7 +111,7 @@ module Parse
74
111
  # @see TRIGGER_NAMES
75
112
  def fetch_trigger(triggerName, className)
76
113
  triggerName = _verify_trigger(triggerName)
77
- safe_class = Parse::API::PathSegment.identifier!(className, kind: "class name")
114
+ safe_class = Parse::API::PathSegment.trigger_class_name!(className, kind: "class name")
78
115
  request :get, "#{HOOKS_PREFIX}triggers/#{safe_class}/#{triggerName}"
79
116
  end
80
117
 
@@ -98,7 +135,7 @@ module Parse
98
135
  # @see Parse::API::Hooks::TRIGGER_NAMES
99
136
  def update_trigger(triggerName, className, url)
100
137
  triggerName = _verify_trigger(triggerName)
101
- safe_class = Parse::API::PathSegment.identifier!(className, kind: "class name")
138
+ safe_class = Parse::API::PathSegment.trigger_class_name!(className, kind: "class name")
102
139
  request :put, "#{HOOKS_PREFIX}triggers/#{safe_class}/#{triggerName}", body: { url: url }
103
140
  end
104
141
 
@@ -109,7 +146,7 @@ module Parse
109
146
  # @see Parse::API::Hooks::TRIGGER_NAMES
110
147
  def delete_trigger(triggerName, className)
111
148
  triggerName = _verify_trigger(triggerName)
112
- safe_class = Parse::API::PathSegment.identifier!(className, kind: "class name")
149
+ safe_class = Parse::API::PathSegment.trigger_class_name!(className, kind: "class name")
113
150
  request :put, "#{HOOKS_PREFIX}triggers/#{safe_class}/#{triggerName}", body: { __op: "Delete" }
114
151
  end
115
152
  end
@@ -84,8 +84,15 @@ module Parse
84
84
  # @param body [Hash] the body of the request.
85
85
  # @param opts [Hash] additional options to pass to the {Parse::Client} request.
86
86
  # @param headers [Hash] additional HTTP headers to send with the request.
87
+ # @param context [Hash, nil] an optional caller context forwarded as the
88
+ # +X-Parse-Cloud-Context+ header. Parse Server maps it to
89
+ # +req.info.context+ inside beforeSave/afterSave cloud triggers.
90
+ # Omit or pass +nil+ to leave behavior unchanged.
87
91
  # @return [Parse::Response]
88
- def create_object(className, body = {}, headers: {}, **opts)
92
+ def create_object(className, body = {}, headers: {}, context: nil, **opts)
93
+ unless context.nil?
94
+ headers = headers.merge(Parse::Protocol::CLOUD_CONTEXT => context.to_json)
95
+ end
89
96
  response = request :post, uri_path(className), body: body, headers: headers, opts: opts
90
97
  response.parse_class = className if response.present?
91
98
  response
@@ -135,8 +142,15 @@ module Parse
135
142
  # @param body [Hash] The key value pairs to update.
136
143
  # @param opts [Hash] additional options to pass to the {Parse::Client} request.
137
144
  # @param headers [Hash] additional HTTP headers to send with the request.
145
+ # @param context [Hash, nil] an optional caller context forwarded as the
146
+ # +X-Parse-Cloud-Context+ header. Parse Server maps it to
147
+ # +req.info.context+ inside beforeSave/afterSave cloud triggers.
148
+ # Omit or pass +nil+ to leave behavior unchanged.
138
149
  # @return [Parse::Response]
139
- def update_object(className, id, body = {}, headers: {}, **opts)
150
+ def update_object(className, id, body = {}, headers: {}, context: nil, **opts)
151
+ unless context.nil?
152
+ headers = headers.merge(Parse::Protocol::CLOUD_CONTEXT => context.to_json)
153
+ end
140
154
  response = request :put, uri_path(className, id), body: body, headers: headers, opts: opts
141
155
  response.parse_class = className if response.present?
142
156
  response
@@ -45,6 +45,39 @@ module Parse
45
45
  s
46
46
  end
47
47
 
48
+ # Parse trigger className pattern: a normal identifier, OR one of Parse
49
+ # Server's `@`-prefixed pseudo-classes (`@File` for file triggers,
50
+ # `@Connect` for the connection-global LiveQuery trigger). The optional
51
+ # leading `@` is the only relaxation; the rest stays path-safe (no `/`,
52
+ # `.`, or `..`).
53
+ TRIGGER_CLASS_PATTERN = /\A@?[A-Za-z_][A-Za-z0-9_]*\z/.freeze
54
+
55
+ # Validate a className used in a webhook-trigger path
56
+ # (`hooks/triggers/<className>/<trigger>`). Same as {.identifier!} but
57
+ # additionally accepts the `@File` / `@Connect` pseudo-classes that Parse
58
+ # Server uses for file and connection triggers. `create_trigger` carries
59
+ # the className in the request BODY (not the path), so it already accepts
60
+ # these; this keeps fetch / update / delete symmetric with create.
61
+ #
62
+ # @param value the className to validate.
63
+ # @param kind [String] human-readable name for error messages.
64
+ # @return [String] the validated className.
65
+ # @raise [ArgumentError] if blank or otherwise fails the pattern.
66
+ def trigger_class_name!(value, kind: "class name")
67
+ s = value.to_s
68
+ if s.empty?
69
+ raise ArgumentError, "#{kind} must not be empty"
70
+ end
71
+ unless TRIGGER_CLASS_PATTERN.match?(s)
72
+ raise ArgumentError,
73
+ "#{kind} #{s.inspect} contains characters that are not allowed in " \
74
+ "a Parse trigger class name. Names must match " \
75
+ "/\\A@?[A-Za-z_][A-Za-z0-9_]*\\z/ (an identifier, optionally an " \
76
+ "@-prefixed pseudo-class such as @File or @Connect)."
77
+ end
78
+ s
79
+ end
80
+
48
81
  # Validate and percent-encode a less-restrictive path segment, used
49
82
  # for file names which can contain hyphens, periods, and other
50
83
  # filename-safe characters but must never contain a literal `/`,
@@ -59,6 +59,100 @@ module Parse
59
59
  server_info.present? ? @server_info[:parseServerVersion] : nil
60
60
  end
61
61
 
62
+ # The `features` block advertised by `GET /serverInfo`. Parse Server
63
+ # surfaces coarse capability groups here (`globalConfig`, `hooks`,
64
+ # `cloudCode`, `logs`, `push`, `schemas`), each a Hash of booleans.
65
+ # This is authoritative where present but intentionally coarse — it
66
+ # does NOT carry fine-grained behavior flags like "public explain" or
67
+ # the LiveQuery `keys` rename. Those are resolved by version inference
68
+ # in {#server_supports?}.
69
+ # @return [Hash] the advertised features block, or `{}` if unavailable.
70
+ def server_features
71
+ info = server_info
72
+ return {} unless info.is_a?(Hash)
73
+ feats = info[:features]
74
+ feats.is_a?(Hash) ? feats : {}
75
+ end
76
+
77
+ # Capability table consumed by {#server_supports?}. Each entry is
78
+ # version-inferred (we cannot read these off the coarse `features`
79
+ # block) with one of two predicates:
80
+ #
81
+ # - `since:` — the capability EXISTS on this version and newer. An
82
+ # unknown/unparseable server version resolves to `true`
83
+ # (fail-open-to-modern: assume the current server line, matching the
84
+ # deprecation gate's posture).
85
+ # - `until:` — the capability existed BELOW this version and was
86
+ # removed/restricted at it. Unknown version resolves to `false`
87
+ # (the modern server no longer offers it).
88
+ #
89
+ # `feature:` (a `[group, flag]` pair) lets a future capability prefer
90
+ # the advertised `features` block when Parse Server genuinely surfaces
91
+ # it there; absent that, the version predicate decides.
92
+ # @!visibility private
93
+ CAPABILITIES = {
94
+ # LiveQuery subscription field projection: the `fields` option was
95
+ # renamed `keys` in Parse Server 7.0.0 (DEPPS9 / #8852). The SDK
96
+ # emits both, so this is informational rather than gating.
97
+ livequery_keys_option: { since: "7.0.0" },
98
+ # Cloud functions encode returned Parse.Object values as `__type`
99
+ # dictionaries: default flipped to `true` in 8.0.0, made
100
+ # unconditional (option removed) in 9.0.0.
101
+ cloud_object_encoding: { since: "8.0.0" },
102
+ # Non-master `explain` on a query: `allowPublicExplain` defaulted to
103
+ # `false` in 9.0.0, so a session-scoped explain that worked on 8.x
104
+ # is rejected on 9.x unless the operator re-enables it.
105
+ public_explain: { until: "9.0.0" },
106
+ # Aggregation `rawValues` / `rawFieldNames` options added in 9.9.0
107
+ # (#10438).
108
+ aggregate_raw_values: { since: "9.9.0" },
109
+ }.freeze
110
+
111
+ # Capability probe against the connected Parse Server. Builds on the
112
+ # already-memoized {#server_info} (no extra round-trip beyond the one
113
+ # `serverInfo` fetch) and the coarse `features` block, falling back to
114
+ # version inference for behavior flags the `features` block does not
115
+ # carry.
116
+ #
117
+ # Fails OPEN to the modern server line: when the server version cannot
118
+ # be determined (offline unit tests, a `serverInfo` outage, a wire
119
+ # surprise), a `since:` capability resolves `true` and an `until:`
120
+ # capability resolves `false` — i.e. "assume the current server",
121
+ # mirroring {#warn_if_deprecated_server_version!}.
122
+ #
123
+ # @example
124
+ # client.server_supports?(:public_explain) # => false on PS 9.x
125
+ # client.server_supports?(:aggregate_raw_values)
126
+ # @param feature [Symbol] a key of {CAPABILITIES}.
127
+ # @return [Boolean] whether the connected server supports the feature.
128
+ # @raise [ArgumentError] for an unknown capability key (typo guard).
129
+ def server_supports?(feature)
130
+ spec = CAPABILITIES[feature]
131
+ raise ArgumentError, "Unknown Parse Server capability #{feature.inspect}" if spec.nil?
132
+
133
+ # Prefer the advertised features block when a capability declares a
134
+ # `[group, flag]` path AND the server actually surfaces it.
135
+ if (path = spec[:feature])
136
+ group, flag = path
137
+ advertised = server_features.dig(group.to_s, flag.to_s)
138
+ advertised = server_features.dig(group, flag) if advertised.nil?
139
+ return advertised == true unless advertised.nil?
140
+ end
141
+
142
+ version = server_version.to_s
143
+ if (floor = spec[:since])
144
+ # Supported on `floor` and newer. Unknown version => assume modern => true.
145
+ return true if version.empty?
146
+ !server_version_below?(version, floor)
147
+ elsif (ceiling = spec[:until])
148
+ # Supported strictly below `ceiling`. Unknown version => assume modern => false.
149
+ return false if version.empty?
150
+ server_version_below?(version, ceiling)
151
+ else
152
+ false
153
+ end
154
+ end
155
+
62
156
  private
63
157
 
64
158
  # One-shot deprecation warning. The check runs once per client
@@ -14,7 +14,11 @@ module Parse
14
14
  # @!visibility private
15
15
  LOGIN_PATH = "login"
16
16
  # @!visibility private
17
+ VERIFY_PASSWORD_PATH = "verifyPassword"
18
+ # @!visibility private
17
19
  REQUEST_PASSWORD_RESET = "requestPasswordReset"
20
+ # @!visibility private
21
+ VERIFICATION_EMAIL_REQUEST = "verificationEmailRequest"
18
22
 
19
23
  # Fetch a {Parse::User} for a given objectId.
20
24
  # @param id [String] the user objectid
@@ -130,6 +134,26 @@ module Parse
130
134
  response
131
135
  end
132
136
 
137
+ # Request that Parse Server (re)send the email-address verification email
138
+ # for a registered, not-yet-verified user. Requires the server to have an
139
+ # email adapter and `verifyUserEmails` enabled; otherwise Parse Server
140
+ # responds with an error. Rate-limited per email like password reset.
141
+ #
142
+ # @param email [String] the Parse user email.
143
+ # @param opts [Hash] additional options to pass to the {Parse::Client} request.
144
+ # @param headers [Hash] additional HTTP headers to send with the request.
145
+ # @return [Parse::Response]
146
+ def request_email_verification(email, headers: {}, **opts)
147
+ rate_key = "emailverify:#{email}"
148
+ check_login_rate_limit!(rate_key)
149
+ body = { email: email }
150
+ response = request :post, VERIFICATION_EMAIL_REQUEST, body: body, opts: opts, headers: headers
151
+ # Indistinguishable found/not-found response, like password reset — count
152
+ # every attempt toward backoff so probing can't reset the counter.
153
+ track_login_attempt(rate_key, false)
154
+ response
155
+ end
156
+
133
157
  # Login a user. Implements client-side rate limiting with exponential
134
158
  # backoff after repeated failures to mitigate brute force attacks.
135
159
  # @param username [String] the Parse user username.
@@ -180,6 +204,37 @@ module Parse
180
204
  response
181
205
  end
182
206
 
207
+ # Verify a user's credentials against Parse Server without minting a session.
208
+ # This is the canonical step-up / re-authentication primitive: it confirms
209
+ # that the username + password combination is correct without producing a
210
+ # new session token on success.
211
+ #
212
+ # Uses the +POST /parse/verifyPassword+ endpoint (credentials in the request
213
+ # BODY, mirroring +login+) rather than the +GET+ form. Parse Server accepts
214
+ # both (same handler, neither master-key gated; the POST variant landed in
215
+ # 7.1.0), but POST keeps the plaintext password out of the URL — and
216
+ # therefore out of server access logs, reverse-proxy logs, the +Referer+
217
+ # header, and the SDK's URL-keyed response cache.
218
+ #
219
+ # On success Parse Server returns the user object (HTTP 200) with the same
220
+ # shape as a login response (minus +sessionToken+). On failure it returns a
221
+ # 4xx with an error body, most commonly:
222
+ # - code 101 (+ERROR_OBJECT_NOT_FOUND+) for an unknown username or wrong password.
223
+ # - code 205 (+ERROR_EMAIL_NOT_FOUND+) when +preventLoginWithUnverifiedEmail+
224
+ # is enabled and the account's email has not been verified.
225
+ #
226
+ # @param username [String] the Parse user username.
227
+ # @param password [String] the Parse user's associated password.
228
+ # @param headers [Hash] additional HTTP headers to send with the request.
229
+ # @param opts [Hash] additional options to pass to the {Parse::Client} request.
230
+ # @return [Parse::Response]
231
+ def verify_password(username, password, headers: {}, **opts)
232
+ body = { username: username, password: password }
233
+ response = request :post, VERIFY_PASSWORD_PATH, body: body, headers: headers, opts: opts
234
+ response.parse_class = Parse::Model::CLASS_USER
235
+ response
236
+ end
237
+
183
238
  # Logout a user by deleting the associated session.
184
239
  # @param session_token [String] the Parse user session token to delete.
185
240
  # @param headers [Hash] additional HTTP headers to send with the request.
@@ -223,7 +278,7 @@ module Parse
223
278
  LOGIN_RATE_LIMIT_TTL = 600
224
279
 
225
280
  # Checks if a login attempt is allowed for the given username.
226
- # @raise [RuntimeError] if the account is temporarily locked out.
281
+ # @raise [Parse::Error::AccountLockoutError] if the account is temporarily locked out.
227
282
  def check_login_rate_limit!(username)
228
283
  @login_rate_limit_mutex ||= Mutex.new
229
284
  @login_rate_limit_mutex.synchronize do
@@ -231,7 +286,8 @@ module Parse
231
286
  return unless entry
232
287
  if entry[:locked_until] && Time.now < entry[:locked_until]
233
288
  wait = (entry[:locked_until] - Time.now).ceil
234
- raise "Login rate limited for '#{username}'. Try again in #{wait} seconds."
289
+ raise Parse::Error::AccountLockoutError,
290
+ "Login rate limited for '#{username}'. Try again in #{wait} seconds."
235
291
  end
236
292
  end
237
293
  end
@@ -105,10 +105,10 @@ module Parse
105
105
 
106
106
  # @!attribute [rw] role_cache_ttl
107
107
  # TTL (seconds) for {Session}'s user-id → role-name cache.
108
- # Default: 120 (2 minutes). Short on purpose: stale role
109
- # data yields incorrect ACL decisions, so the cache is sized
110
- # to amortize within a single request/turn but expire well
111
- # inside the response time the operator notices a role grant.
108
+ # Default: 30. Short on purpose: stale role data yields
109
+ # incorrect ACL decisions, so the cache is sized to amortize
110
+ # within a single request/turn but expire well inside the
111
+ # response time the operator notices a role grant or revoke.
112
112
  # @return [Integer]
113
113
  attr_accessor :role_cache_ttl
114
114
 
@@ -141,7 +141,7 @@ module Parse
141
141
  # @param session_cache_ttl [Integer] session-token cache TTL
142
142
  # (seconds). Default: 3600.
143
143
  # @param role_cache_ttl [Integer] role-name cache TTL (seconds).
144
- # Default: 120.
144
+ # Default: 30.
145
145
  # @example
146
146
  # Parse::AtlasSearch.configure(enabled: true, default_index: "default")
147
147
  def configure(enabled: true,
@@ -195,7 +195,7 @@ module Parse
195
195
  @allow_raw = default_allow_raw
196
196
  @require_session_token = false
197
197
  @session_cache_ttl = 3600
198
- @role_cache_ttl = 120
198
+ @role_cache_ttl = 30
199
199
  @session_cache = Session::MemoryCache.new
200
200
  @role_cache = Session::MemoryCache.new
201
201
  @master_warned = false
@@ -1015,7 +1015,7 @@ module Parse
1015
1015
  @allow_raw = nil
1016
1016
  @require_session_token = false
1017
1017
  @session_cache_ttl = 3600
1018
- @role_cache_ttl = 120
1018
+ @role_cache_ttl = 30
1019
1019
  @session_cache = Session::MemoryCache.new
1020
1020
  @role_cache = Session::MemoryCache.new
1021
1021
  @master_warned = false
@@ -74,6 +74,11 @@ module Parse
74
74
  Parse::Protocol::MASTER_KEY,
75
75
  Parse::Protocol::API_KEY,
76
76
  Parse::Protocol::SESSION_TOKEN,
77
+ # Caller-supplied Cloud Code context (X-Parse-Cloud-Context) carries
78
+ # `context.to_json`, which may hold PII / request metadata. Redact it in
79
+ # the header log; the body/as_json log path scrubs sensitive sub-values
80
+ # of context separately.
81
+ Parse::Protocol::CLOUD_CONTEXT,
77
82
  "X-Parse-JavaScript-Key",
78
83
  "Authorization",
79
84
  "Cookie",
@@ -31,6 +31,10 @@ module Parse
31
31
  # The request header field for MongoDB read preference.
32
32
  # Supported values: PRIMARY, PRIMARY_PREFERRED, SECONDARY, SECONDARY_PREFERRED, NEAREST
33
33
  READ_PREFERENCE = "X-Parse-Read-Preference"
34
+ # The request header field for threading a caller-supplied context object
35
+ # through a write or cloud-function call. Parse Server maps this header to
36
+ # +req.info.context+ and flows it through beforeSave/afterSave triggers.
37
+ CLOUD_CONTEXT = "X-Parse-Cloud-Context"
34
38
 
35
39
  # Valid read preference values for MongoDB
36
40
  READ_PREFERENCES = %w[PRIMARY PRIMARY_PREFERRED SECONDARY SECONDARY_PREFERRED NEAREST].freeze
data/lib/parse/client.rb CHANGED
@@ -1414,9 +1414,53 @@ module Parse
1414
1414
  # Unwrap the `{ "result" => ... }` envelope from a successful cloud-code response.
1415
1415
  # Guards against unusual server payloads (non-Hash bodies) by returning the raw
1416
1416
  # result rather than raising TypeError on `String#[]`/`Integer#[]`.
1417
+ #
1418
+ # Parse Server 8.0 flipped `encodeParseObjectInCloudFunction` to true and 9.0
1419
+ # removed the opt-out, so a cloud function that returns a Parse object now
1420
+ # yields a `__type`-encoded dictionary (`{"__type":"Object","className":...}`)
1421
+ # where a pre-8.x caller received a plain attribute Hash. We decode those
1422
+ # self-describing envelopes back into `Parse::Object` / `Parse::Pointer` so the
1423
+ # value a caller sees is consistent across server versions and matches what
1424
+ # every other Parse SDK returns. Decoding is conservative: only a fully-shaped
1425
+ # Object/Pointer envelope is converted, and an Object of an UNregistered class
1426
+ # is left as a raw Hash (building it would degrade to a field-less Pointer).
1427
+ # Plain Hashes and arbitrary `__type` app data pass through untouched.
1417
1428
  def self._extract_cloud_result(response)
1418
1429
  r = response.result
1419
- r.is_a?(Hash) ? r["result"] : r
1430
+ value = r.is_a?(Hash) ? r["result"] : r
1431
+ _decode_cloud_value(value)
1432
+ end
1433
+
1434
+ # @!visibility private
1435
+ # Recursively decode Parse-encoded values in a cloud-code result. See
1436
+ # {._extract_cloud_result} for the rationale and the conservatism rules.
1437
+ def self._decode_cloud_value(value)
1438
+ case value
1439
+ when Array
1440
+ value.map { |v| _decode_cloud_value(v) }
1441
+ when Hash
1442
+ type = value["__type"] || value[:__type]
1443
+ class_name = value["className"] || value[:className]
1444
+ object_id = value["objectId"] || value[:objectId]
1445
+ if type == Parse::Model::TYPE_POINTER && class_name && object_id
1446
+ # Pointers carry no attributes, so building one is lossless even for
1447
+ # an unregistered class (yields a Parse::Pointer).
1448
+ Parse::Object.build(value)
1449
+ elsif type == Parse::Model::TYPE_OBJECT && class_name && object_id &&
1450
+ Parse::Model.find_class(class_name)
1451
+ # Only build a full object when the class is registered; otherwise
1452
+ # Parse::Object.build collapses to a field-less Pointer and we'd lose
1453
+ # the attributes — better to hand back the raw Hash.
1454
+ Parse::Object.build(value)
1455
+ else
1456
+ # Plain Hash, partial envelope, or non-object `__type` (Date/GeoPoint/
1457
+ # File/Bytes, or literal app data): leave the node shape intact and
1458
+ # only recurse into nested values so embedded objects still decode.
1459
+ value.transform_values { |v| _decode_cloud_value(v) }
1460
+ end
1461
+ else
1462
+ value
1463
+ end
1420
1464
  end
1421
1465
 
1422
1466
  # Helper method to trigger cloud jobs and get results.
@@ -1491,6 +1535,9 @@ module Parse
1491
1535
  # @option opts [Symbol] :client The client connection to use.
1492
1536
  # @option opts [Boolean] :raw Whether to return the raw response object.
1493
1537
  # @option opts [Boolean] :master_key Whether to use the master key for this request.
1538
+ # @option opts [Hash, nil] :context An optional caller context forwarded as the
1539
+ # +X-Parse-Cloud-Context+ header. Parse Server maps it to +req.info.context+
1540
+ # in the function handler and flows it through beforeSave/afterSave triggers.
1494
1541
  # @return [Object] the result data of the response. nil if there was an error.
1495
1542
  def self.call_function(name, body = {}, **opts)
1496
1543
  conn = opts[:session] || opts[:client] || :default
@@ -1500,7 +1547,13 @@ module Parse
1500
1547
  request_opts[:session_token] = opts[:session_token] if opts[:session_token]
1501
1548
  request_opts[:master_key] = opts[:master_key] if opts[:master_key]
1502
1549
 
1503
- response = Parse::Client.client(conn).call_function(name, body, opts: request_opts)
1550
+ # Build call kwargs; only forward context: when explicitly supplied so
1551
+ # call sites that do not use context produce the same opts hash that
1552
+ # existing mock expectations match against.
1553
+ call_kwargs = { opts: request_opts }
1554
+ call_kwargs[:context] = opts[:context] unless opts[:context].nil?
1555
+
1556
+ response = Parse::Client.client(conn).call_function(name, body, **call_kwargs)
1504
1557
  return response if opts[:raw].present?
1505
1558
  if response.error?
1506
1559
  Parse::Client._safe_warn("CloudCodeError", response, name: name)