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
@@ -0,0 +1,255 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "thread"
5
+
6
+ module Parse
7
+ module Embeddings
8
+ # Per-tenant cumulative embedding spend cap.
9
+ #
10
+ # The agent `semantic_search` tool embeds attacker-controlled text
11
+ # (chat queries) on every call. Without a cap, a tenant — or an
12
+ # adversary driving an agent — can run up unbounded embedding-provider
13
+ # cost. {SpendCap} tracks the cumulative number of *tokens* embedded
14
+ # per tenant inside a sliding time window and HARD-REFUSES (raises
15
+ # {Exceeded}) once a tenant would exceed its limit. This is distinct
16
+ # from {Parse::Agent::RateLimiter}, which bounds request *count* per
17
+ # window; the spend cap bounds embedding *volume* (a proxy for cost).
18
+ #
19
+ # == Disabled by default
20
+ #
21
+ # With no configured limit the cap is a no-op — {.charge!} records
22
+ # nothing and never raises. Operators opt in:
23
+ #
24
+ # Parse::Embeddings::SpendCap.configure(limit_tokens: 1_000_000, window: 3600)
25
+ # Parse::Embeddings::SpendCap.configure(:acme_tenant, limit_tokens: 50_000)
26
+ #
27
+ # A per-tenant limit (second form) overrides the default for that
28
+ # tenant. The reserved key {DEFAULT_KEY} sets the fallback applied to
29
+ # every tenant without an explicit limit.
30
+ #
31
+ # == Token estimation
32
+ #
33
+ # Callers pass an explicit token count, or use {.estimate_tokens} (a
34
+ # chars/4 heuristic — the same approximation the agent layer uses for
35
+ # its context-token budgets). The cap is intentionally an estimate: it
36
+ # exists to bound runaway cost, not to bill precisely.
37
+ #
38
+ # Thread-safe: all state lives behind a single mutex.
39
+ module SpendCap
40
+ # Raised when a tenant would exceed its token cap. Carries the
41
+ # limit, the already-used count (within the window), and a
42
+ # `retry_after` hint (seconds until enough of the window rolls off
43
+ # to admit the rejected charge — `nil` if the charge can never fit).
44
+ class Exceeded < StandardError
45
+ attr_reader :tenant_id, :limit, :used, :requested, :window, :retry_after
46
+
47
+ def initialize(tenant_id:, limit:, used:, requested:, window:, retry_after:)
48
+ @tenant_id = tenant_id
49
+ @limit = limit
50
+ @used = used
51
+ @requested = requested
52
+ @window = window
53
+ @retry_after = retry_after
54
+ super(
55
+ "Embedding spend cap exceeded for tenant #{tenant_id.inspect}: " \
56
+ "#{used}+#{requested} tokens would exceed #{limit}/#{window}s." \
57
+ "#{retry_after ? " Retry after #{retry_after.round(1)}s." : " Request exceeds the cap outright."}"
58
+ )
59
+ end
60
+ end
61
+
62
+ # Fallback bucket key for charges with no tenant identity, and the
63
+ # key under which {.configure} (with no explicit tenant) sets the
64
+ # default limit applied to every tenant lacking an override.
65
+ DEFAULT_KEY = :__default__
66
+
67
+ # Default sliding window (seconds) when none is configured.
68
+ DEFAULT_WINDOW = 3600
69
+
70
+ class << self
71
+ # Configure the cap. Two forms:
72
+ #
73
+ # configure(limit_tokens:, window:) # default for all tenants
74
+ # configure(tenant_id, limit_tokens:, window:) # override one tenant
75
+ #
76
+ # `limit_tokens: nil` disables the cap for that scope (the default
77
+ # scope when no tenant is given).
78
+ #
79
+ # @param tenant_id [Object, nil] tenant to override, or nil for
80
+ # the global default.
81
+ # @param limit_tokens [Integer, nil] token ceiling per window.
82
+ # @param window [Integer] sliding window length in seconds.
83
+ # @return [void]
84
+ def configure(tenant_id = nil, limit_tokens:, window: DEFAULT_WINDOW)
85
+ key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
86
+ unless limit_tokens.nil?
87
+ li = Integer(limit_tokens)
88
+ raise ArgumentError, "SpendCap: limit_tokens must be positive (got #{li})." if li <= 0
89
+ end
90
+ w = Integer(window)
91
+ raise ArgumentError, "SpendCap: window must be positive (got #{w})." if w <= 0
92
+ mutex.synchronize do
93
+ limits[key] = limit_tokens.nil? ? nil : { limit: Integer(limit_tokens), window: w }
94
+ end
95
+ nil
96
+ end
97
+
98
+ # Charge `tokens` against `tenant_id`'s budget. HARD-REFUSES by
99
+ # raising {Exceeded} when the charge would push the tenant over
100
+ # its limit within the window; otherwise records the charge and
101
+ # returns the new in-window total.
102
+ #
103
+ # No-op (returns nil) when no limit applies to the tenant.
104
+ #
105
+ # @param tenant_id [Object, nil] tenant identity (nil → {DEFAULT_KEY}).
106
+ # @param tokens [Integer] tokens to charge (>= 0).
107
+ # @return [Integer, nil] new in-window total, or nil if uncapped.
108
+ # @raise [Exceeded]
109
+ def charge!(tenant_id:, tokens:)
110
+ t = Integer(tokens)
111
+ raise ArgumentError, "SpendCap: tokens must be >= 0 (got #{t})." if t.negative?
112
+ key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
113
+
114
+ mutex.synchronize do
115
+ cfg = limit_for(key)
116
+ return nil if cfg.nil? # uncapped
117
+
118
+ window = cfg[:window]
119
+ limit = cfg[:limit]
120
+ now = monotonic
121
+ entries = prune(key, now, window)
122
+ used = entries.sum { |e| e[1] }
123
+
124
+ if used + t > limit
125
+ raise Exceeded.new(
126
+ tenant_id: key, limit: limit, used: used, requested: t,
127
+ window: window, retry_after: retry_after_for(entries, t, limit, window, now),
128
+ )
129
+ end
130
+ entries << [now, t] if t.positive?
131
+ used + t
132
+ end
133
+ end
134
+
135
+ # Current in-window token usage for a tenant (0 when uncapped or
136
+ # idle). Does not mutate.
137
+ #
138
+ # @param tenant_id [Object, nil]
139
+ # @return [Integer]
140
+ def usage(tenant_id: nil)
141
+ key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
142
+ mutex.synchronize do
143
+ cfg = limit_for(key)
144
+ return 0 if cfg.nil?
145
+ prune(key, monotonic, cfg[:window]).sum { |e| e[1] }
146
+ end
147
+ end
148
+
149
+ # Estimate token count from a String.
150
+ #
151
+ # The familiar "~4 characters per token" ratio only holds for
152
+ # ASCII. CJK, emoji, and other multibyte text run closer to one
153
+ # token per codepoint in a real tokenizer, so a pure
154
+ # `chars / 4` estimate undercounts such input by up to ~4x — and
155
+ # since this estimate is the sole basis for the hard-refuse
156
+ # decision, that lets a caller feeding multibyte text reach ~4x
157
+ # the real embedding volume before the cap trips. Take the larger
158
+ # of the char-based and byte-based estimates so multibyte input
159
+ # bills at least as much as its UTF-8 byte length implies.
160
+ #
161
+ # @param text [String]
162
+ # @return [Integer]
163
+ def estimate_tokens(text)
164
+ str = text.to_s
165
+ chars = (str.length / 4.0).ceil
166
+ bytes = (str.bytesize / 4.0).ceil
167
+ [chars, bytes].max
168
+ end
169
+
170
+ # Clear recorded usage (all tenants, or one). Limits are retained.
171
+ #
172
+ # @param tenant_id [Object, nil]
173
+ def reset!(tenant_id = nil)
174
+ mutex.synchronize do
175
+ if tenant_id.nil?
176
+ @buckets = {}
177
+ else
178
+ buckets.delete(tenant_id)
179
+ end
180
+ end
181
+ nil
182
+ end
183
+
184
+ # Remove all configured limits AND recorded usage. Mainly for
185
+ # tests — returns the cap to its disabled-by-default state.
186
+ def reset_all!
187
+ mutex.synchronize do
188
+ @limits = {}
189
+ @buckets = {}
190
+ end
191
+ nil
192
+ end
193
+
194
+ private
195
+
196
+ MUTEX_INIT = Mutex.new
197
+ private_constant :MUTEX_INIT
198
+
199
+ def mutex
200
+ @mutex ||= MUTEX_INIT.synchronize { @mutex ||= Mutex.new }
201
+ end
202
+
203
+ def limits
204
+ @limits ||= {}
205
+ end
206
+
207
+ def buckets
208
+ @buckets ||= {}
209
+ end
210
+
211
+ # Resolve the effective limit config for a key: an explicit
212
+ # per-tenant entry wins; otherwise the DEFAULT_KEY entry; nil when
213
+ # neither is set (uncapped). A key explicitly set to nil disables
214
+ # the cap for that tenant even if a default exists.
215
+ def limit_for(key)
216
+ if limits.key?(key)
217
+ limits[key]
218
+ else
219
+ limits[DEFAULT_KEY]
220
+ end
221
+ end
222
+
223
+ # Drop entries older than the window; returns the (mutated) live
224
+ # entry list for the key.
225
+ def prune(key, now, window)
226
+ entries = (buckets[key] ||= [])
227
+ cutoff = now - window
228
+ entries.reject! { |e| e[0] <= cutoff }
229
+ entries
230
+ end
231
+
232
+ # Seconds until enough in-window tokens roll off to admit a charge
233
+ # of `requested` tokens. nil when the request alone exceeds the
234
+ # limit (it can never fit).
235
+ def retry_after_for(entries, requested, limit, window, now)
236
+ return nil if requested > limit
237
+ need_to_free = (entries.sum { |e| e[1] } + requested) - limit
238
+ return 0.0 if need_to_free <= 0
239
+ freed = 0
240
+ entries.sort_by { |e| e[0] }.each do |ts, tok|
241
+ freed += tok
242
+ if freed >= need_to_free
243
+ return [(ts + window) - now, 0.0].max
244
+ end
245
+ end
246
+ nil
247
+ end
248
+
249
+ def monotonic
250
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -554,3 +554,4 @@ require_relative "embeddings/voyage"
554
554
  require_relative "embeddings/jina"
555
555
  require_relative "embeddings/qwen"
556
556
  require_relative "embeddings/local_http"
557
+ require_relative "embeddings/spend_cap"
@@ -363,7 +363,7 @@ module Parse
363
363
  # events can arrive. Optional — callers may still capture the
364
364
  # returned subscription and register callbacks later.
365
365
  # @return [Subscription]
366
- def subscribe(class_name, where: {}, fields: nil, session_token: nil,
366
+ def subscribe(class_name, where: {}, fields: nil, keys: nil, watch: nil, session_token: nil,
367
367
  use_master_key: false, &block)
368
368
  # Handle Parse::Object subclass
369
369
  if class_name.is_a?(Class) && class_name < Parse::Object
@@ -396,6 +396,8 @@ module Parse
396
396
  class_name: class_name,
397
397
  query: where,
398
398
  fields: fields,
399
+ keys: keys,
400
+ watch: watch,
399
401
  session_token: session_token,
400
402
  use_master_key: use_master_key,
401
403
  )
@@ -62,12 +62,22 @@ module Parse
62
62
  # @return [Parse::LiveQuery::Client] the LiveQuery client
63
63
  attr_reader :client
64
64
 
65
- # @return [Array<String>] fields to watch for changes (nil = all fields)
65
+ # @return [Array<String>] field projection for returned events
66
+ # (nil = all fields). Parse Server 7.0 renamed this subscription
67
+ # option from `fields` to `keys`; {#keys} is the canonical alias.
66
68
  attr_reader :fields
69
+ # @return [Array<String>] alias for {#fields} under Parse Server's
70
+ # post-7.0 `keys` name.
71
+ alias_method :keys, :fields
67
72
 
68
73
  # @return [String, nil] session token for ACL-aware subscriptions
69
74
  attr_reader :session_token
70
75
 
76
+ # @return [Array<String>, nil] field names that trigger update events when
77
+ # changed (PS 7.0+ `watch` option). +nil+ means all field changes trigger
78
+ # update events.
79
+ attr_reader :watch
80
+
71
81
  # Create a new subscription
72
82
  # @param client [Parse::LiveQuery::Client] the LiveQuery client
73
83
  # @param class_name [String] Parse class name
@@ -85,13 +95,16 @@ module Parse
85
95
  # elevated; on a non-admin connection the client warns and the
86
96
  # subscription stays ACL-scoped. For mixed scoped + admin needs,
87
97
  # use two separate clients. Defaults to false.
88
- def initialize(client:, class_name:, query: {}, fields: nil,
89
- session_token: nil, use_master_key: false)
98
+ def initialize(client:, class_name:, query: {}, fields: nil, keys: nil,
99
+ session_token: nil, use_master_key: false, watch: nil)
90
100
  @monitor = Monitor.new
91
101
  @client = client
92
102
  @class_name = class_name
93
103
  @query = query
94
- @fields = fields
104
+ # `keys` is the post-7.0 name; accept either and prefer the explicit
105
+ # `keys:` when both are supplied.
106
+ @fields = keys.nil? ? fields : keys
107
+ @watch = watch
95
108
  @session_token = session_token
96
109
  @use_master_key = use_master_key == true
97
110
  @request_id = generate_request_id
@@ -239,7 +252,21 @@ module Parse
239
252
  },
240
253
  }
241
254
 
242
- msg[:query][:fields] = fields if fields&.any?
255
+ if fields&.any?
256
+ # Parse Server 7.0 (DEPPS9 / #8852) renamed the subscription field-
257
+ # projection option from `fields` to `keys`. PS 7+ reads `keys` and
258
+ # ignores `fields`; PS < 7 reads `fields`. Emit BOTH so projection is
259
+ # honored on every supported server — sending an extra key the server
260
+ # ignores is harmless, while sending only `fields` silently disables
261
+ # projection (events return all columns) on PS 7+.
262
+ msg[:query][:keys] = fields
263
+ msg[:query][:fields] = fields
264
+ end
265
+ # PS 7.0 (#8028) `watch`: fire update events only when the named fields
266
+ # change. Distinct from field projection (`keys`/`fields`): `watch`
267
+ # controls which field mutations generate an update event; `keys` controls
268
+ # which fields are returned in the event payload.
269
+ msg[:query][:watch] = watch if watch&.any?
243
270
  msg[:sessionToken] = session_token if session_token
244
271
  # The subscribe frame deliberately NEVER carries `masterKey`.
245
272
  # Parse Server's `_handleSubscribe` does not read it — master-key
@@ -91,8 +91,10 @@ module Parse
91
91
  # artist.save
92
92
  #
93
93
  # You may also set default ACLs for your subclasses by using {Parse::Object.set_default_acl}.
94
- # These will be get applied for newly created instances. All subclasses have
95
- # public read and write enabled by default.
94
+ # These will get applied for newly created instances. Unless overridden, subclasses
95
+ # inherit the shipped `:owner_else_private` policy records are private
96
+ # (master-only) until an owner is resolved at save time. Use {Parse::Object.set_default_acl}
97
+ # or {Parse::Object.acl_policy} to grant broader access.
96
98
  #
97
99
  # class AdminData < Parse::Object
98
100
  #
@@ -155,12 +155,60 @@ module Parse
155
155
  property :name
156
156
 
157
157
  # @!attribute query
158
- # The query constraints that define which installations belong to this audience.
159
- # This is stored as a hash matching the Installation query format.
160
- # @return [Hash] The query constraint hash.
158
+ # The query constraints that define which installations belong to this
159
+ # audience, as a Hash matching the Installation query format.
160
+ #
161
+ # On the wire this is persisted as a JSON **string**, matching Parse
162
+ # Server's built-in `_Audience.query` column (typed `String`, not Object).
163
+ # Assigning a Hash and reading a Hash back is handled transparently. The
164
+ # previous `property :query, :object` emitted a JSON object, so every save
165
+ # of a hash query was rejected by the server with a schema mismatch
166
+ # ("expected String but got Object").
167
+ # @return [ActiveSupport::HashWithIndifferentAccess, nil] the query hash.
161
168
  # @example
162
169
  # audience.query = { "deviceType" => "ios", "appVersion" => { "$gte" => "2.0" } }
163
- property :query, :object
170
+ property :query_json, :string, field: :query
171
+
172
+ # JSON-encode a Hash/Array assigned to the query field before the `:string`
173
+ # property coercion runs. Every assignment path — `query=`,
174
+ # `query_constraint=`, mass-assignment via `new(query:)`, and server
175
+ # hydration — funnels through `format_value`, so intercepting here (rather
176
+ # than the public setter, which mass-assignment bypasses) is the single
177
+ # reliable place to keep the wire value valid JSON (`{"k":"v"}`) instead of
178
+ # Ruby's `Hash#to_s` (`{"k"=>"v"}`). A String passed in (e.g. a row loaded
179
+ # from the server) falls through to the normal string coercion untouched.
180
+ def format_value(key, val, data_type = nil)
181
+ if key == :query_json && (val.is_a?(Hash) || val.is_a?(Array))
182
+ return val.to_json
183
+ end
184
+ super
185
+ end
186
+
187
+ # The query constraints as a Hash (decoded from the stored JSON string).
188
+ # @return [ActiveSupport::HashWithIndifferentAccess, nil]
189
+ def query
190
+ raw = query_json
191
+ case raw
192
+ when nil, "" then nil
193
+ when Hash then raw.with_indifferent_access
194
+ when String
195
+ decoded = begin
196
+ JSON.parse(raw)
197
+ rescue JSON::ParserError
198
+ nil
199
+ end
200
+ decoded.is_a?(Hash) ? decoded.with_indifferent_access : decoded
201
+ else
202
+ raw
203
+ end
204
+ end
205
+
206
+ # Assign the audience query. Accepts a Hash (preferred) or a pre-encoded
207
+ # JSON String; either form is persisted as a JSON string.
208
+ # @param value [Hash, String, nil]
209
+ def query=(value)
210
+ self.query_json = value
211
+ end
164
212
 
165
213
  # Alias for query to match Parse Server naming conventions.
166
214
  # @return [Hash] The query constraint hash.
@@ -28,6 +28,59 @@ module Parse
28
28
 
29
29
  # 125 Error code indicating that the email address was invalid.
30
30
  class InvalidEmailAddress < Error; end
31
+
32
+ # Error code 205 (Parse::Response::ERROR_EMAIL_NOT_FOUND) raised by
33
+ # {Parse::User.login!} and {Parse::User#verify_password} when Parse Server
34
+ # returns code 205 because +preventLoginWithUnverifiedEmail+ is enabled and
35
+ # the account's email address has not been verified.
36
+ #
37
+ # It is a SUBCLASS of {AuthenticationError} on purpose: before this typed
38
+ # error existed, the unverified-email rejection raised a plain
39
+ # +AuthenticationError+, so existing callers wrapping {Parse::User.login!}
40
+ # in +rescue AuthenticationError+ must keep catching it (subclassing keeps
41
+ # that contract — making it a sibling would be a silent breaking change).
42
+ # Callers who want to special-case the unverified-email path just rescue
43
+ # this narrower subclass FIRST.
44
+ #
45
+ # @example
46
+ # begin
47
+ # Parse::User.login!(username, password)
48
+ # rescue Parse::Error::EmailNotVerifiedError
49
+ # # Prompt user to check their inbox and verify their email
50
+ # rescue Parse::Error::AuthenticationError
51
+ # # Wrong credentials or other login failure (still catches the above
52
+ # # too, if no narrower rescue precedes it)
53
+ # end
54
+ class EmailNotVerifiedError < AuthenticationError; end
55
+
56
+ # Raised by {Parse::Client} when the SDK's client-side login rate-limit
57
+ # guard fires — i.e. the same username has failed
58
+ # {Parse::API::Users::LOGIN_MAX_FAILURES} or more times and the exponential
59
+ # back-off window has not yet elapsed.
60
+ #
61
+ # The class is a subclass of {AuthenticationError} so that a single
62
+ # <tt>rescue Parse::Error::AuthenticationError</tt> handler covers both
63
+ # wrong-credential failures and lockout situations. Callers that need to
64
+ # distinguish the lockout case just rescue this narrower subclass first.
65
+ # Because the previous implementation raised a plain +RuntimeError+, there
66
+ # is no prior +AuthenticationError+ rescue contract to preserve — this is
67
+ # a new typed entry in the login-failure taxonomy.
68
+ #
69
+ # Note that +Parse::Error < StandardError+, so a bare +rescue+ or
70
+ # +rescue StandardError+ still catches this error.
71
+ #
72
+ # @example
73
+ # begin
74
+ # Parse::User.login!(username, password)
75
+ # rescue Parse::Error::AccountLockoutError => e
76
+ # # Too many failed attempts — tell the user how long to wait
77
+ # retry_in = e.message[/\d+/]
78
+ # render_lockout_page(retry_in: retry_in)
79
+ # rescue Parse::Error::AuthenticationError
80
+ # # Wrong credentials (or other login failure — also catches lockout
81
+ # # if no narrower rescue precedes it)
82
+ # end
83
+ class AccountLockoutError < AuthenticationError; end
31
84
  end
32
85
 
33
86
  # The main class representing the _User table in Parse. A user can either be signed up or anonymous.
@@ -504,15 +557,55 @@ module Parse
504
557
  if hash.key?(:authData) || hash.key?("authData") ||
505
558
  hash.key?(:auth_data) || hash.key?("auth_data")
506
559
  hash = hash.dup
560
+ raw_auth = hash[:authData] || hash["authData"] ||
561
+ hash[:auth_data] || hash["auth_data"]
507
562
  hash.delete(:authData)
508
563
  hash.delete("authData")
509
564
  hash.delete(:auth_data)
510
565
  hash.delete("auth_data")
566
+ # Preserve ONLY a non-sensitive MFA status derived from the stripped
567
+ # authData, so #mfa_enabled? / #mfa_status (and the #disable_mfa!
568
+ # guard) work after an ordinary fetch without retaining the TOTP
569
+ # secret, recovery codes, mobile number, or any OAuth provider token.
570
+ # Non-MFA authData still strips to nil exactly as before.
571
+ safe = sanitized_mfa_authdata(raw_auth)
572
+ hash["authData"] = safe if safe
511
573
  end
512
574
  end
513
575
  super(hash, dirty_track: dirty_track, filter_protected: filter_protected, protected_set: protected_set)
514
576
  end
515
577
 
578
+ # @!visibility private
579
+ # Reduce a server-returned +authData+ hash to a leak-safe MFA status.
580
+ # Parse Server returns +authData.mfa+ as +{ "secret" => ..., "recovery" =>
581
+ # [...] }+ (the raw TOTP secret and one-time recovery codes) even on a
582
+ # user's own session-token read, so the value itself must never be retained.
583
+ # This keeps only +{ "mfa" => { "status" => "enabled" } }+ when MFA is
584
+ # configured, and returns +nil+ otherwise (preserving the prior
585
+ # strip-to-nil behavior for OAuth-only / non-MFA authData).
586
+ # @return [Hash, nil]
587
+ def sanitized_mfa_authdata(raw)
588
+ return nil unless raw.is_a?(Hash)
589
+ mfa = raw["mfa"] || raw[:mfa]
590
+ return nil unless mfa.is_a?(Hash)
591
+
592
+ status = mfa["status"] || mfa[:status]
593
+ # An EXPLICIT non-"enabled" status is authoritative: treat the user
594
+ # as disabled even if a stale `secret`/`recovery` lingers in the
595
+ # blob. Without this, a residual credential would override an
596
+ # explicit `status: "disabled"` and make `mfa_enabled?` report true
597
+ # for a user who has turned MFA off.
598
+ return nil if status.is_a?(String) && status != "enabled"
599
+
600
+ recovery = mfa["recovery"] || mfa[:recovery]
601
+ enabled = status == "enabled" ||
602
+ (mfa["secret"] || mfa[:secret]).present? ||
603
+ (recovery.is_a?(Array) ? recovery.any? : recovery.present?) ||
604
+ (mfa["mobile"] || mfa[:mobile]).present?
605
+
606
+ enabled ? { "mfa" => { "status" => "enabled" } } : nil
607
+ end
608
+
516
609
  # @return [Boolean] true if this user is anonymous (i.e. created
517
610
  # via the +authData.anonymous+ provider rather than via signup
518
611
  # with a username/password or a real OAuth provider).
@@ -664,6 +757,17 @@ module Parse
664
757
  Parse::User.request_password_reset(email)
665
758
  end
666
759
 
760
+ # Request that Parse Server (re)send this user's email-address verification
761
+ # email. The server must have an email adapter and `verifyUserEmails` enabled.
762
+ # @return [Boolean] true if the request was accepted, false otherwise.
763
+ # @raise [Parse::Error::ServiceUnavailableError] if Parse Server returns a
764
+ # 500/503 (e.g. no emailAdapter / `verifyUserEmails` disabled).
765
+ # @see Parse::User.request_email_verification
766
+ def request_email_verification
767
+ return false if email.nil?
768
+ Parse::User.request_email_verification(email)
769
+ end
770
+
667
771
  # You may set a password for this user when you are creating them. Parse never returns a
668
772
  # @param passwd The user's password to be used for signing up.
669
773
  # @raise [Parse::Error::UsernameMissingError] If username is missing.
@@ -1069,9 +1173,21 @@ module Parse
1069
1173
  # Self-fetch trust: see {.login}.
1070
1174
  with_authdata_trust { Parse::User.build(response.result) }
1071
1175
  else
1072
- raise Parse::Error::AuthenticationError,
1073
- "Parse::User.login! failed for #{username.inspect}: " \
1074
- "#{response.error || "HTTP #{response.http_status}"} (code=#{response.code.inspect})"
1176
+ case response.code
1177
+ when Parse::Response::ERROR_EMAIL_NOT_FOUND
1178
+ # Parse Server throws code 205 (EMAIL_NOT_FOUND) when
1179
+ # +preventLoginWithUnverifiedEmail+ is set and the account's email
1180
+ # address has not yet been verified. Raise the typed error so callers
1181
+ # can direct the user to verify their inbox without catching every
1182
+ # AuthenticationError.
1183
+ raise Parse::Error::EmailNotVerifiedError,
1184
+ "Parse::User.login! failed for #{username.inspect}: " \
1185
+ "email address is not verified (code=205)"
1186
+ else
1187
+ raise Parse::Error::AuthenticationError,
1188
+ "Parse::User.login! failed for #{username.inspect}: " \
1189
+ "#{response.error || "HTTP #{response.http_status}"} (code=#{response.code.inspect})"
1190
+ end
1075
1191
  end
1076
1192
  end
1077
1193
 
@@ -1096,6 +1212,26 @@ module Parse
1096
1212
  response.success?
1097
1213
  end
1098
1214
 
1215
+ # Request that Parse Server (re)send the email-address verification email for
1216
+ # a registered, not-yet-verified user. The server must have an email adapter
1217
+ # and `verifyUserEmails` enabled.
1218
+ # @example
1219
+ # # pass a user object
1220
+ # Parse::User.request_email_verification(user)
1221
+ # # or an email
1222
+ # Parse::User.request_email_verification("user@example.com")
1223
+ # @param email [String] The user's email address (or a {Parse::User}).
1224
+ # @return [Boolean] True/false if the request was accepted.
1225
+ # @raise [Parse::Error::ServiceUnavailableError] if Parse Server returns a
1226
+ # 500/503 (e.g. no emailAdapter / `verifyUserEmails` disabled). Callers that
1227
+ # branch on the Boolean should rescue this.
1228
+ def self.request_email_verification(email)
1229
+ email = email.email if email.is_a?(Parse::User)
1230
+ return false if email.blank?
1231
+ response = client.request_email_verification(email)
1232
+ response.success?
1233
+ end
1234
+
1099
1235
  # Same as `session!` but returns nil if a user was not found or sesion token was invalid.
1100
1236
  # @return [User] the user matching this active token, otherwise nil.
1101
1237
  # @see #session!
@@ -1260,6 +1396,47 @@ module Parse
1260
1396
  active_session_count > 1
1261
1397
  end
1262
1398
 
1399
+ # Verify this user's password without minting a session token.
1400
+ #
1401
+ # Delegates to the +GET /parse/verifyPassword+ endpoint (Parse Server
1402
+ # 7.1.0+) using this user's +username+ and the supplied +password+. The
1403
+ # check is purely credential validation — no session is created on
1404
+ # success, and the user's existing sessions are unaffected.
1405
+ #
1406
+ # Use this as a step-up authentication gate: before allowing a sensitive
1407
+ # action (e.g. changing an email address or deleting an account), call
1408
+ # +verify_password+ to confirm the caller still knows the password.
1409
+ #
1410
+ # @param password [String] the password to verify.
1411
+ # @return [Boolean] +true+ if the credentials are valid.
1412
+ # @raise [Parse::Error::EmailNotVerifiedError] when the account exists but
1413
+ # +preventLoginWithUnverifiedEmail+ is enabled and the email has not been
1414
+ # verified (Parse Server error code 205). The caller may want to prompt
1415
+ # the user to check their inbox rather than treating this as a wrong-
1416
+ # password failure.
1417
+ # @raise [Parse::Error::AuthenticationError] when the username does not
1418
+ # exist or the password is wrong (code 101, +OBJECT_NOT_FOUND+).
1419
+ # @return [Boolean]
1420
+ # @example
1421
+ # # Step-up check before a destructive action
1422
+ # if user.verify_password(params[:current_password])
1423
+ # user.destroy
1424
+ # end
1425
+ def verify_password(password)
1426
+ response = client.verify_password(username.to_s, password.to_s)
1427
+ return true if response.success?
1428
+
1429
+ case response.code
1430
+ when Parse::Response::ERROR_EMAIL_NOT_FOUND
1431
+ raise Parse::Error::EmailNotVerifiedError,
1432
+ "verify_password failed: email address is not verified (code=205)"
1433
+ else
1434
+ raise Parse::Error::AuthenticationError,
1435
+ "verify_password failed: " \
1436
+ "#{response.error || "HTTP #{response.http_status}"} (code=#{response.code.inspect})"
1437
+ end
1438
+ end
1439
+
1263
1440
  # Return the transitive upward closure of role names this user
1264
1441
  # inherits permissions from.
1265
1442
  #