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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/CHANGELOG.md +461 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +160 -3
- data/Rakefile +52 -3
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +5 -0
- data/docs/mcp_guide.md +59 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +55 -2
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +180 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/querying.rb +3 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +17 -0
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +252 -7
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- 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
|
data/lib/parse/embeddings.rb
CHANGED
|
@@ -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>]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/parse/model/acl.rb
CHANGED
|
@@ -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
|
|
95
|
-
#
|
|
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
|
|
159
|
-
#
|
|
160
|
-
#
|
|
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 :
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
#
|