parse-stack-next 5.2.1 → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +616 -0
  5. data/Gemfile +7 -0
  6. data/Gemfile.lock +12 -4
  7. data/README.md +296 -3
  8. data/Rakefile +243 -41
  9. data/docs/atlas_vector_search_guide.md +86 -2
  10. data/docs/client_sdk_guide.md +38 -0
  11. data/docs/mcp_guide.md +119 -4
  12. data/docs/mongodb_direct_guide.md +93 -1
  13. data/docs/usage_guide.md +11 -1
  14. data/docs/webhooks_guide.md +418 -0
  15. data/examples/README.md +46 -0
  16. data/examples/basic_client.rb +93 -0
  17. data/examples/basic_server.rb +109 -0
  18. data/examples/live_query_listener.rb +98 -0
  19. data/examples/rag_chatbot.rb +221 -0
  20. data/examples/webhook_server.rb +111 -0
  21. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  22. data/lib/parse/agent/tools.rb +45 -5
  23. data/lib/parse/api/aggregate.rb +7 -1
  24. data/lib/parse/api/cloud_functions.rb +12 -4
  25. data/lib/parse/api/hooks.rb +46 -9
  26. data/lib/parse/api/objects.rb +16 -2
  27. data/lib/parse/api/path_segment.rb +33 -0
  28. data/lib/parse/api/server.rb +94 -0
  29. data/lib/parse/api/users.rb +58 -2
  30. data/lib/parse/atlas_search.rb +7 -7
  31. data/lib/parse/client/body_builder.rb +5 -0
  32. data/lib/parse/client/protocol.rb +4 -0
  33. data/lib/parse/client.rb +174 -9
  34. data/lib/parse/embeddings/spend_cap.rb +255 -0
  35. data/lib/parse/embeddings.rb +1 -0
  36. data/lib/parse/live_query/client.rb +3 -1
  37. data/lib/parse/live_query/subscription.rb +32 -5
  38. data/lib/parse/model/acl.rb +4 -2
  39. data/lib/parse/model/associations/belongs_to.rb +47 -0
  40. data/lib/parse/model/classes/audience.rb +52 -4
  41. data/lib/parse/model/classes/user.rb +200 -3
  42. data/lib/parse/model/core/embed_managed.rb +113 -0
  43. data/lib/parse/model/core/pluralized_aliases.rb +30 -0
  44. data/lib/parse/model/core/properties.rb +27 -0
  45. data/lib/parse/model/core/querying.rb +73 -1
  46. data/lib/parse/model/core/vector_searchable.rb +161 -0
  47. data/lib/parse/model/file.rb +35 -2
  48. data/lib/parse/model/object.rb +28 -5
  49. data/lib/parse/mongodb.rb +7 -1
  50. data/lib/parse/pipeline_security.rb +5 -3
  51. data/lib/parse/query/constraints.rb +29 -0
  52. data/lib/parse/query.rb +265 -27
  53. data/lib/parse/retrieval/agent_tool.rb +49 -0
  54. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  55. data/lib/parse/retrieval/reranker.rb +157 -0
  56. data/lib/parse/retrieval/retriever.rb +110 -23
  57. data/lib/parse/stack/version.rb +1 -1
  58. data/lib/parse/stack.rb +173 -1
  59. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  60. data/lib/parse/vector_search/hybrid.rb +578 -0
  61. data/lib/parse/webhooks/payload.rb +399 -11
  62. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  63. data/lib/parse/webhooks.rb +215 -3
  64. data/scripts/docker/Dockerfile.parse +5 -1
  65. data/scripts/docker/docker-compose.test.yml +31 -0
  66. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  67. data/scripts/docker/preflight.sh +76 -0
  68. data/scripts/start-parse.sh +52 -4
  69. metadata +16 -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
@@ -331,7 +331,86 @@ module Parse
331
331
  attr_accessor :cache
332
332
  attr_writer :retry_limit
333
333
  attr_reader :application_id, :api_key, :master_key, :server_url
334
+ # @return [String, nil] the session token bound to this client, if any
335
+ # (see the `:session_token` constructor option). Applied as the
336
+ # lowest-priority auth fallback on every request.
337
+ attr_reader :session_token
334
338
  alias_method :app_id, :application_id
339
+
340
+ # Redacted inspection. The default Ruby `#inspect` would dump every ivar,
341
+ # exposing the master key and any bound session token in cleartext wherever
342
+ # a client is logged or surfaced in an error reporter. Show only the
343
+ # connection identity and a boolean for each credential's presence.
344
+ def inspect
345
+ "#<#{self.class.name} server_url=#{@server_url.inspect} " \
346
+ "app_id=#{@application_id.inspect} master_key=#{@master_key ? "[FILTERED]" : "nil"} " \
347
+ "session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
348
+ end
349
+
350
+ # A NEW non-master {Parse::Client} that mirrors THIS client's connection
351
+ # settings (`server_url` / `application_id` / `api_key`) but carries no
352
+ # master key and binds +session_token+, so it acts on the server as that
353
+ # user (ACL / CLP / `protectedFields` enforced, no master-key fallback).
354
+ # This is the general primitive behind {Parse::Webhooks::Payload#user_client}
355
+ # and {Parse::User#session_client}: derive a user-scoped client from a
356
+ # configured (e.g. master) client without re-specifying the connection.
357
+ #
358
+ # user_client = Parse.client.become(user.session_token)
359
+ # Parse::Query.new("Post", client: user_client).results # as the user
360
+ #
361
+ # @param session_token [String, #session_token] the token to bind. A blank
362
+ # token yields a tokenless non-master client (anonymous REST).
363
+ # @return [Parse::Client]
364
+ def become(session_token)
365
+ Parse::Client.new(
366
+ server_url: @server_url,
367
+ app_id: @application_id,
368
+ api_key: @api_key,
369
+ master_key: nil,
370
+ session_token: session_token,
371
+ )
372
+ end
373
+
374
+ # A NEW anonymous client that mirrors THIS client's connection but carries
375
+ # neither a master key nor a session token — every request it makes is
376
+ # unauthenticated (app-id + REST key only). Use it to drop the bound user
377
+ # identity for a one-off public read without mutating a shared client.
378
+ # Equivalent to {#become} with no token.
379
+ # @return [Parse::Client]
380
+ def anonymous
381
+ become(nil)
382
+ end
383
+
384
+ # Run a block with this client's bound {#session_token} active as the
385
+ # ambient session, so every query / object operation inside it that resolves
386
+ # the default client (e.g. `Post.count`, `Post.all`, `obj.save`) is
387
+ # authorized by Parse Server as that user — ACL and CLP enforced, master key
388
+ # suppressed — without threading `session_token:` through each call.
389
+ #
390
+ # This is the client-receiver flavor of {Parse.with_session} (and mirrors
391
+ # {Parse::User#with_session}); it scopes by binding the token as the AMBIENT
392
+ # session — it does not re-route operations through this client object, so
393
+ # the connection used inside the block is still the resolved default client.
394
+ # If you need operations to run against a different client, pass that client
395
+ # explicitly (e.g. `Parse::Query.new("Post", client: #{become}(...))`).
396
+ #
397
+ # total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
398
+ #
399
+ # Scopes REST-routed operations (`find` / `get` / `count` / `save`). It does
400
+ # NOT scope mongo-direct queries (`results_direct`, `aggregate`, Atlas
401
+ # search): those resolve auth from the query's own `session_token:` /
402
+ # `acl_user:` and, absent that, run in MASTER mode — so a mongo-direct read
403
+ # inside this block is a full master read, not anonymous. Scope mongo-direct
404
+ # explicitly with a per-query `session_token:` or a scoped {Parse::Agent}.
405
+ #
406
+ # @raise [ArgumentError] if this client has no bound session token (scoping
407
+ # would be a no-op and almost certainly a mistake).
408
+ # @return the value of the block.
409
+ def with_session(&block)
410
+ raise ArgumentError, "Parse::Client#with_session requires a block" unless block_given?
411
+ raise ArgumentError, "Parse::Client#with_session requires a client with a bound session_token" if @session_token.nil?
412
+ Parse.with_session(@session_token, &block)
413
+ end
335
414
  # The client can support multiple sessions. The first session created, will be placed
336
415
  # under the default session tag. The :default session will be the default client to be used
337
416
  # by the other classes including Parse::Query and Parse::Objects
@@ -415,6 +494,14 @@ module Parse
415
494
  # @option opts [String] :master_key The Parse application master key (optional).
416
495
  # If this key is set, it will be sent on every request sent by the client
417
496
  # and your models. Defaults to ENV['PARSE_SERVER_MASTER_KEY'].
497
+ # @option opts [String] :session_token An optional session token bound to
498
+ # this client. When set, every request that does not pass an explicit
499
+ # `session_token:` / `use_master_key: true` and is not inside a
500
+ # `Parse.with_session` block sends this token (and suppresses the master
501
+ # key), so the client transparently acts as that user. Precedence is
502
+ # explicit per-call > `Parse.with_session` ambient > this bound token.
503
+ # Typically paired with `master_key: nil` to build a user-scoped client
504
+ # (see {Parse::Webhooks::Payload#user_client}).
418
505
  # @option opts [Boolean, Symbol] :logging Controls request/response logging.
419
506
  # - `true` - Enable logging at :info level
420
507
  # - `:debug` - Enable verbose logging with headers and body content
@@ -492,7 +579,26 @@ module Parse
492
579
  @server_url = opts[:server_url] || ENV["PARSE_SERVER_URL"] || Parse::Protocol::SERVER_URL
493
580
  @application_id = opts[:application_id] || opts[:app_id] || ENV["PARSE_SERVER_APPLICATION_ID"] || ENV["PARSE_APP_ID"]
494
581
  @api_key = opts[:api_key] || opts[:rest_api_key] || ENV["PARSE_SERVER_REST_API_KEY"] || ENV["PARSE_API_KEY"]
495
- @master_key = opts[:master_key] || ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
582
+ # Distinguish an explicit `master_key: nil` (deliberately a non-master
583
+ # client — what user_client / session_client / user_agent rely on) from
584
+ # an omitted key (fall back to ENV). The previous `opts[:master_key] ||
585
+ # ENV[...]` form silently re-inherited the process master key for the
586
+ # explicit-nil case, putting a "non-master" client back into master mode
587
+ # in any deployment that exports PARSE_SERVER_MASTER_KEY / PARSE_MASTER_KEY.
588
+ @master_key = if opts.key?(:master_key)
589
+ opts[:master_key]
590
+ else
591
+ ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
592
+ end
593
+ # Optional token bound to this client; applied per request as the
594
+ # lowest-priority auth fallback (see #request). Normalize blank/whitespace
595
+ # to nil so it never trips the "token present" branch at request time
596
+ # (where `present?` is false for whitespace) and silently fall back to the
597
+ # master key on a master-configured client.
598
+ bound_token = opts[:session_token]
599
+ bound_token = bound_token.session_token if bound_token.respond_to?(:session_token)
600
+ bound_token = bound_token.to_s.strip
601
+ @session_token = bound_token.empty? ? nil : bound_token
496
602
 
497
603
  @require_https = opts.fetch(:require_https, ENV["PARSE_REQUIRE_HTTPS"] == "true")
498
604
  @allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false)
@@ -1016,14 +1122,20 @@ module Parse
1016
1122
 
1017
1123
  token = opts[:session_token]
1018
1124
  # When no explicit token was passed AND the caller didn't ask to send
1019
- # the master key, fall through to the fiber-local ambient set by
1020
- # `Parse.with_session`. Explicit `use_master_key: true` is treated as
1021
- # a deliberate admin call and skips the ambient otherwise an
1022
- # `admin.do_thing(use_master_key: true)` nested inside a
1023
- # `with_session(user)` block would silently downgrade.
1125
+ # the master key, fall through to (in order) the fiber-local ambient set
1126
+ # by `Parse.with_session`, then this client's own bound `@session_token`.
1127
+ # Explicit `use_master_key: true` is treated as a deliberate admin call
1128
+ # and skips both — otherwise an `admin.do_thing(use_master_key: true)`
1129
+ # nested inside a `with_session(user)` block (or on a token-bound client)
1130
+ # would silently downgrade. The ambient wins over the bound token so a
1131
+ # `with_session` override inside a user-scoped client still takes effect.
1024
1132
  if token.nil? && !(explicit_master && opts[:use_master_key] == true)
1025
1133
  ambient = Parse.current_session_token
1026
- token = ambient if ambient.is_a?(String) && !ambient.empty?
1134
+ # A whitespace-only ambient must not count as present: otherwise it
1135
+ # blocks the bound-token fallback below and then fails the later
1136
+ # `token.present?` check, silently sending the master key instead.
1137
+ token = ambient if ambient.is_a?(String) && !ambient.strip.empty?
1138
+ token = @session_token if (token.nil? || token.to_s.strip.empty?) && @session_token
1027
1139
  end
1028
1140
  if token.present?
1029
1141
  token = token.session_token if token.respond_to?(:session_token)
@@ -1302,9 +1414,53 @@ module Parse
1302
1414
  # Unwrap the `{ "result" => ... }` envelope from a successful cloud-code response.
1303
1415
  # Guards against unusual server payloads (non-Hash bodies) by returning the raw
1304
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.
1305
1428
  def self._extract_cloud_result(response)
1306
1429
  r = response.result
1307
- 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
1308
1464
  end
1309
1465
 
1310
1466
  # Helper method to trigger cloud jobs and get results.
@@ -1379,6 +1535,9 @@ module Parse
1379
1535
  # @option opts [Symbol] :client The client connection to use.
1380
1536
  # @option opts [Boolean] :raw Whether to return the raw response object.
1381
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.
1382
1541
  # @return [Object] the result data of the response. nil if there was an error.
1383
1542
  def self.call_function(name, body = {}, **opts)
1384
1543
  conn = opts[:session] || opts[:client] || :default
@@ -1388,7 +1547,13 @@ module Parse
1388
1547
  request_opts[:session_token] = opts[:session_token] if opts[:session_token]
1389
1548
  request_opts[:master_key] = opts[:master_key] if opts[:master_key]
1390
1549
 
1391
- 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)
1392
1557
  return response if opts[:raw].present?
1393
1558
  if response.error?
1394
1559
  Parse::Client._safe_warn("CloudCodeError", response, name: name)