parse-stack-next 4.5.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- data/parse-stack.png +0 -0
data/lib/parse/live_query.rb
CHANGED
|
@@ -6,9 +6,12 @@ require_relative "model/core/errors"
|
|
|
6
6
|
module Parse
|
|
7
7
|
# LiveQuery provides real-time data subscriptions for reactive applications.
|
|
8
8
|
# It uses WebSockets to receive push notifications when data changes on the server.
|
|
9
|
+
# Stable since Parse Stack 3.0.0.
|
|
9
10
|
#
|
|
10
|
-
# @note
|
|
11
|
-
#
|
|
11
|
+
# @note LiveQuery requires an explicit opt-in before any subscription will
|
|
12
|
+
# open a network connection. This is a safety gate (operator must
|
|
13
|
+
# consciously enable the WebSocket egress surface), not a stability
|
|
14
|
+
# warning. Set the toggle once at boot:
|
|
12
15
|
#
|
|
13
16
|
# Parse.live_query_enabled = true
|
|
14
17
|
#
|
|
@@ -60,10 +63,12 @@ module Parse
|
|
|
60
63
|
# Default LiveQuery events
|
|
61
64
|
EVENTS = %i[create update delete enter leave].freeze
|
|
62
65
|
|
|
63
|
-
# Error raised when LiveQuery is used but not
|
|
66
|
+
# Error raised when LiveQuery is used but the opt-in toggle has not
|
|
67
|
+
# been set. Opening a WebSocket is a network-egress action that the
|
|
68
|
+
# operator must consciously enable; we refuse to do it implicitly.
|
|
64
69
|
class NotEnabledError < Error
|
|
65
70
|
def initialize
|
|
66
|
-
super("LiveQuery
|
|
71
|
+
super("LiveQuery must be explicitly enabled before opening a subscription. Set Parse.live_query_enabled = true")
|
|
67
72
|
end
|
|
68
73
|
end
|
|
69
74
|
end
|
|
@@ -318,10 +318,10 @@ module Parse
|
|
|
318
318
|
# to pointer format. Use this when sending data to Parse Server (saves, webhooks).
|
|
319
319
|
# When false (default), full objects are serialized for API responses.
|
|
320
320
|
# @example Default - full objects for API responses
|
|
321
|
-
#
|
|
321
|
+
# workspace.members.as_json
|
|
322
322
|
# # => [{"objectId"=>"abc", "name"=>"Alice", ...}, ...]
|
|
323
323
|
# @example Pointers only for storage
|
|
324
|
-
#
|
|
324
|
+
# workspace.members.as_json(pointers_only: true)
|
|
325
325
|
# # => [{"__type"=>"Pointer", "className"=>"Member", "objectId"=>"abc"}, ...]
|
|
326
326
|
def as_json(opts = nil)
|
|
327
327
|
opts ||= {}
|
|
@@ -323,12 +323,23 @@ module Parse
|
|
|
323
323
|
|
|
324
324
|
# @!visibility private
|
|
325
325
|
module ClassMethods
|
|
326
|
-
attr_writer :relations
|
|
326
|
+
attr_writer :relations, :has_many_associations
|
|
327
327
|
|
|
328
328
|
def relations
|
|
329
329
|
@relations ||= {}
|
|
330
330
|
end
|
|
331
331
|
|
|
332
|
+
# Static metadata for all `has_many` declarations on this class,
|
|
333
|
+
# covering all three storage modes (:query, :array, :relation).
|
|
334
|
+
# Populated at DSL time so codegen tools (e.g.
|
|
335
|
+
# Parse::GraphQL::TypeGenerator) can recover the target class and
|
|
336
|
+
# storage without parsing method closures. Keyed by the local
|
|
337
|
+
# accessor name.
|
|
338
|
+
# @return [Hash{Symbol => Hash}]
|
|
339
|
+
def has_many_associations
|
|
340
|
+
@has_many_associations ||= {}
|
|
341
|
+
end
|
|
342
|
+
|
|
332
343
|
# Examples:
|
|
333
344
|
# has_many :fans, as: :users, through: :relation, field: "awesomeFans"
|
|
334
345
|
# has_many :songs
|
|
@@ -356,6 +367,16 @@ module Parse
|
|
|
356
367
|
klassName = (opts[:as] || key).to_parse_class singularize: true
|
|
357
368
|
foreign_field = (opts[:field] || parse_class.columnize).to_sym
|
|
358
369
|
|
|
370
|
+
self.has_many_associations[key.to_sym] = {
|
|
371
|
+
target_class: klassName,
|
|
372
|
+
storage: :query,
|
|
373
|
+
foreign_field: foreign_field,
|
|
374
|
+
field: nil,
|
|
375
|
+
required: false,
|
|
376
|
+
scope_only: opts[:scope_only] == true,
|
|
377
|
+
scoped: scope.is_a?(Proc),
|
|
378
|
+
}
|
|
379
|
+
|
|
359
380
|
define_method(key) do |*args, &block|
|
|
360
381
|
return [] if @id.nil?
|
|
361
382
|
query = Parse::Query.new(klassName, limit: :max)
|
|
@@ -451,6 +472,16 @@ module Parse
|
|
|
451
472
|
self.fields.merge!(key => :array, parse_field => :array)
|
|
452
473
|
end
|
|
453
474
|
|
|
475
|
+
self.has_many_associations[key.to_sym] = {
|
|
476
|
+
target_class: klassName,
|
|
477
|
+
storage: access_type, # :relation or :array
|
|
478
|
+
foreign_field: nil,
|
|
479
|
+
field: parse_field,
|
|
480
|
+
required: !!opts[:required],
|
|
481
|
+
scope_only: false,
|
|
482
|
+
scoped: false,
|
|
483
|
+
}
|
|
484
|
+
|
|
454
485
|
self.field_map.merge!(key => parse_field)
|
|
455
486
|
# dirty tracking
|
|
456
487
|
define_attribute_methods key
|
|
@@ -112,6 +112,16 @@ module Parse
|
|
|
112
112
|
|
|
113
113
|
# @!visibility private
|
|
114
114
|
module ClassMethods
|
|
115
|
+
attr_writer :has_one_associations
|
|
116
|
+
|
|
117
|
+
# Static metadata for all `has_one` declarations on this class.
|
|
118
|
+
# Populated at DSL time so codegen tools (e.g. Parse::GraphQL::TypeGenerator)
|
|
119
|
+
# can recover the target class and foreign field without parsing method
|
|
120
|
+
# closures. Keyed by the local accessor name.
|
|
121
|
+
# @return [Hash{Symbol => Hash}]
|
|
122
|
+
def has_one_associations
|
|
123
|
+
@has_one_associations ||= {}
|
|
124
|
+
end
|
|
115
125
|
|
|
116
126
|
# has one are not property but instance scope methods
|
|
117
127
|
def has_one(key, scope = nil, **opts)
|
|
@@ -120,6 +130,13 @@ module Parse
|
|
|
120
130
|
foreign_field = opts[:field].to_sym
|
|
121
131
|
_ivar = :"@_has_one_#{key}" # reserved for future caching
|
|
122
132
|
|
|
133
|
+
self.has_one_associations[key.to_sym] = {
|
|
134
|
+
target_class: klassName,
|
|
135
|
+
foreign_field: foreign_field,
|
|
136
|
+
scope_only: opts[:scope_only] == true,
|
|
137
|
+
scoped: scope.is_a?(Proc),
|
|
138
|
+
}
|
|
139
|
+
|
|
123
140
|
if self.method_defined?(key)
|
|
124
141
|
warn "Creating has_one :#{key} association. Will overwrite existing method #{self}##{key}."
|
|
125
142
|
end
|
|
@@ -106,10 +106,10 @@ module Parse
|
|
|
106
106
|
# is false), only serialize fields that were actually fetched. This prevents
|
|
107
107
|
# autofetch from being triggered during serialization of partially hydrated objects.
|
|
108
108
|
# @example Default - pointers for storage
|
|
109
|
-
#
|
|
110
|
-
# # => [{"__type"=>"Pointer", "className"=>"
|
|
109
|
+
# post.assets.as_json
|
|
110
|
+
# # => [{"__type"=>"Pointer", "className"=>"Document", "objectId"=>"abc"}, ...]
|
|
111
111
|
# @example Full objects for API responses (only fetched fields, no autofetch)
|
|
112
|
-
#
|
|
112
|
+
# post.assets.as_json(pointers_only: false)
|
|
113
113
|
# # => [{"objectId"=>"abc", "file"=>{...}, "caption"=>"...", ...}, ...]
|
|
114
114
|
def as_json(opts = nil)
|
|
115
115
|
opts ||= {}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
# Note: Do not require "../object" here - this file is loaded from object.rb
|
|
5
5
|
# and adding that require would create a circular dependency.
|
|
6
6
|
|
|
7
|
+
require "securerandom"
|
|
8
|
+
|
|
7
9
|
module Parse
|
|
8
10
|
class Error
|
|
9
11
|
# 200 Error code indicating that the username is missing or empty.
|
|
@@ -245,6 +247,68 @@ module Parse
|
|
|
245
247
|
# logged-in user can still see their own `email_verified` flag.
|
|
246
248
|
guard :email_verified, :master_only
|
|
247
249
|
|
|
250
|
+
# @!visibility private
|
|
251
|
+
# Thread-local key used by {.with_authdata_trust} to mark the
|
|
252
|
+
# current hydration as a legitimate self-fetch (login/signup/MFA/
|
|
253
|
+
# `/users/me`). Outside that scope, {#apply_attributes!} strips
|
|
254
|
+
# +authData+ from incoming server JSON so a `_User` query/find that
|
|
255
|
+
# crosses ACL boundaries cannot leak another user's federated-identity
|
|
256
|
+
# tokens into the in-memory object.
|
|
257
|
+
AUTHDATA_TRUST_KEY = :__parse_stack_user_authdata_trusted
|
|
258
|
+
|
|
259
|
+
class << self
|
|
260
|
+
# @!visibility private
|
|
261
|
+
# Run +block+ in a scope where the next {Parse::User#apply_attributes!}
|
|
262
|
+
# call is permitted to hydrate +authData+ from the response. Used by
|
|
263
|
+
# +login+/+login!+/+session!+/+create+/+link_auth_data!+/MFA paths
|
|
264
|
+
# where the row being hydrated is provably the authenticating user.
|
|
265
|
+
def with_authdata_trust
|
|
266
|
+
prior = Thread.current[AUTHDATA_TRUST_KEY]
|
|
267
|
+
Thread.current[AUTHDATA_TRUST_KEY] = true
|
|
268
|
+
begin
|
|
269
|
+
yield
|
|
270
|
+
ensure
|
|
271
|
+
Thread.current[AUTHDATA_TRUST_KEY] = prior
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @!visibility private
|
|
276
|
+
# True iff the calling thread is currently inside a
|
|
277
|
+
# {.with_authdata_trust} scope.
|
|
278
|
+
def authdata_trusted?
|
|
279
|
+
Thread.current[AUTHDATA_TRUST_KEY] == true
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @!visibility private
|
|
284
|
+
# Defense-in-depth strip of +authData+ on the way into the in-memory
|
|
285
|
+
# User. Parse Server returns +authData+ on +GET /users/:id+ to any
|
|
286
|
+
# caller with ACL read on the row, and the default +_User+ ACL is
|
|
287
|
+
# permissive in many deployments — without this filter, fetching a
|
|
288
|
+
# different user (or iterating a +Parse::Query.new(User)+ result set)
|
|
289
|
+
# would expose their OAuth +access_token+ / +id_token+ to anyone who
|
|
290
|
+
# JSON-renders the result (Rails views, agent tool output, logging).
|
|
291
|
+
#
|
|
292
|
+
# The strip runs unconditionally unless the caller is inside a
|
|
293
|
+
# {.with_authdata_trust} scope, which is set by the self-fetch
|
|
294
|
+
# paths in this file (login/login!/session!/create/link_auth_data!/
|
|
295
|
+
# unlink_auth_data!) and by the MFA login extension. Trusted callers
|
|
296
|
+
# pass through to +super+ with the hash untouched. The PROTECTED
|
|
297
|
+
# mass-assignment filter that runs inside +super+ is unaffected.
|
|
298
|
+
def apply_attributes!(hash, dirty_track: false, filter_protected: nil, protected_set: nil)
|
|
299
|
+
if hash.is_a?(Hash) && !self.class.authdata_trusted?
|
|
300
|
+
if hash.key?(:authData) || hash.key?("authData") ||
|
|
301
|
+
hash.key?(:auth_data) || hash.key?("auth_data")
|
|
302
|
+
hash = hash.dup
|
|
303
|
+
hash.delete(:authData)
|
|
304
|
+
hash.delete("authData")
|
|
305
|
+
hash.delete(:auth_data)
|
|
306
|
+
hash.delete("auth_data")
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
super(hash, dirty_track: dirty_track, filter_protected: filter_protected, protected_set: protected_set)
|
|
310
|
+
end
|
|
311
|
+
|
|
248
312
|
# @return [Boolean] true if this user is anonymous (i.e. created
|
|
249
313
|
# via the +authData.anonymous+ provider rather than via signup
|
|
250
314
|
# with a username/password or a real OAuth provider).
|
|
@@ -266,7 +330,7 @@ module Parse
|
|
|
266
330
|
def link_auth_data!(service_name, **data)
|
|
267
331
|
response = client.set_service_auth_data(id, service_name, data)
|
|
268
332
|
raise Parse::Client::ResponseError, response if response.error?
|
|
269
|
-
apply_attributes!(response.result)
|
|
333
|
+
self.class.with_authdata_trust { apply_attributes!(response.result) }
|
|
270
334
|
end
|
|
271
335
|
|
|
272
336
|
# Removes third-party authentication data for this user
|
|
@@ -276,7 +340,99 @@ module Parse
|
|
|
276
340
|
def unlink_auth_data!(service_name)
|
|
277
341
|
response = client.set_service_auth_data(id, service_name, nil)
|
|
278
342
|
raise Parse::Client::ResponseError, response if response.error?
|
|
279
|
-
apply_attributes!(response.result)
|
|
343
|
+
self.class.with_authdata_trust { apply_attributes!(response.result) }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Upgrade an anonymous user (one created via the +authData.anonymous+
|
|
347
|
+
# provider) into a full username/password account. This is the
|
|
348
|
+
# SDK-side counterpart of the Parse JS SDK's
|
|
349
|
+
# +_linkWith('username', ...)+ flow — it sends a single
|
|
350
|
+
# +PUT /users/:id+ with the new credentials and an explicit
|
|
351
|
+
# +authData: { anonymous: nil }+ unlink in the same body, then
|
|
352
|
+
# narrowly applies the server's response to the in-memory user.
|
|
353
|
+
#
|
|
354
|
+
# The +authData.anonymous+ unlink is essential: leaving the anonymous
|
|
355
|
+
# provider attached after assigning a username would let anyone else
|
|
356
|
+
# who somehow learned the (random) anonymous id silently log in as
|
|
357
|
+
# the freshly-named account, a documented Parse foot-gun.
|
|
358
|
+
#
|
|
359
|
+
# @param username [String] the username to claim. Must be unique.
|
|
360
|
+
# @param password [String] the password to set on the account.
|
|
361
|
+
# @param email [String, nil] optional email address. Must be unique
|
|
362
|
+
# if provided.
|
|
363
|
+
# @raise [Parse::Error::AuthenticationError] when this instance has
|
|
364
|
+
# no attached +@session_token+, no objectId, or is not anonymous.
|
|
365
|
+
# @raise [Parse::Error::UsernameMissingError] when +username+ is blank.
|
|
366
|
+
# @raise [Parse::Error::PasswordMissingError] when +password+ is blank.
|
|
367
|
+
# @raise [Parse::Error::UsernameTakenError] when Parse Server reports
|
|
368
|
+
# the username already exists.
|
|
369
|
+
# @raise [Parse::Error::EmailTakenError] when Parse Server reports
|
|
370
|
+
# the email already exists.
|
|
371
|
+
# @raise [Parse::Error::InvalidEmailAddress] when Parse Server
|
|
372
|
+
# reports the email is malformed.
|
|
373
|
+
# @raise [Parse::Client::ResponseError] for any other error response.
|
|
374
|
+
# @return [Boolean] true on success.
|
|
375
|
+
def upgrade_anonymous!(username:, password:, email: nil)
|
|
376
|
+
require_self_session!(:upgrade_anonymous!)
|
|
377
|
+
if @id.nil? || @id.to_s.empty?
|
|
378
|
+
raise Parse::Error::AuthenticationError,
|
|
379
|
+
"Parse::User#upgrade_anonymous! requires a saved user (no objectId)"
|
|
380
|
+
end
|
|
381
|
+
unless anonymous?
|
|
382
|
+
raise Parse::Error::AuthenticationError,
|
|
383
|
+
"Parse::User#upgrade_anonymous! is only valid for anonymous users " \
|
|
384
|
+
"(authData.anonymous is not present on this instance)"
|
|
385
|
+
end
|
|
386
|
+
if username.nil? || username.to_s.empty?
|
|
387
|
+
raise Parse::Error::UsernameMissingError, "upgrade_anonymous! requires a username."
|
|
388
|
+
end
|
|
389
|
+
if password.nil? || password.to_s.empty?
|
|
390
|
+
raise Parse::Error::PasswordMissingError, "upgrade_anonymous! requires a password."
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
body = {
|
|
394
|
+
username: username.to_s,
|
|
395
|
+
password: password.to_s,
|
|
396
|
+
# Explicitly unlink the anonymous provider in the same request
|
|
397
|
+
# that claims the new credentials — otherwise the account
|
|
398
|
+
# remains takeover-able via the anonymous id.
|
|
399
|
+
authData: { anonymous: nil },
|
|
400
|
+
}
|
|
401
|
+
body[:email] = email.to_s if email.is_a?(String) && !email.empty?
|
|
402
|
+
|
|
403
|
+
response = client.update_user(@id, body, session_token: @session_token)
|
|
404
|
+
|
|
405
|
+
if response.success?
|
|
406
|
+
result = response.result || {}
|
|
407
|
+
@updated_at = result["updatedAt"] || @updated_at
|
|
408
|
+
# Parse Server may rotate the session token on a credential
|
|
409
|
+
# change; apply it narrowly if present without going through the
|
|
410
|
+
# full property writer chain.
|
|
411
|
+
if result["sessionToken"].is_a?(String) && !result["sessionToken"].empty?
|
|
412
|
+
@session_token = result["sessionToken"]
|
|
413
|
+
end
|
|
414
|
+
@auth_data.delete("anonymous") if @auth_data.is_a?(Hash)
|
|
415
|
+
@username = username.to_s
|
|
416
|
+
@email = email.to_s if email.is_a?(String) && !email.empty?
|
|
417
|
+
@password = nil
|
|
418
|
+
changes_applied!
|
|
419
|
+
clear_partial_fetch_state!
|
|
420
|
+
return true
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
case response.code
|
|
424
|
+
when Parse::Response::ERROR_USERNAME_MISSING
|
|
425
|
+
raise Parse::Error::UsernameMissingError, response
|
|
426
|
+
when Parse::Response::ERROR_PASSWORD_MISSING
|
|
427
|
+
raise Parse::Error::PasswordMissingError, response
|
|
428
|
+
when Parse::Response::ERROR_USERNAME_TAKEN
|
|
429
|
+
raise Parse::Error::UsernameTakenError, response
|
|
430
|
+
when Parse::Response::ERROR_EMAIL_TAKEN
|
|
431
|
+
raise Parse::Error::EmailTakenError, response
|
|
432
|
+
when Parse::Response::ERROR_EMAIL_INVALID
|
|
433
|
+
raise Parse::Error::InvalidEmailAddress, response
|
|
434
|
+
end
|
|
435
|
+
raise Parse::Client::ResponseError, response
|
|
280
436
|
end
|
|
281
437
|
|
|
282
438
|
# @!visibility private
|
|
@@ -425,7 +581,7 @@ module Parse
|
|
|
425
581
|
# telling us what the account currently looks like. (Compare
|
|
426
582
|
# signup, where we narrow to an allow-list because a brand-new
|
|
427
583
|
# account has no legitimate authData to report.)
|
|
428
|
-
apply_attributes! response.result
|
|
584
|
+
self.class.with_authdata_trust { apply_attributes! response.result }
|
|
429
585
|
# Drop the plaintext password from memory now that the login
|
|
430
586
|
# has succeeded. Direct ivar assignment so the dirty tracker
|
|
431
587
|
# doesn't record this clear as a pending change.
|
|
@@ -545,10 +701,14 @@ module Parse
|
|
|
545
701
|
body.delete("__parse_stack_trusted_authdata")) : false
|
|
546
702
|
assert_create_body_safe!(body) unless trusted
|
|
547
703
|
strip_server_controlled_keys!(body)
|
|
548
|
-
response = client.create_user(body, opts
|
|
704
|
+
response = client.create_user(body, **opts)
|
|
549
705
|
if response.success?
|
|
550
706
|
body.delete :password # clear password before merging
|
|
551
|
-
|
|
707
|
+
# Self-fetch trust: the response.result describes the user we
|
|
708
|
+
# just created, so any returned authData IS that user's own
|
|
709
|
+
# federated-identity payload — allow it through the hydration
|
|
710
|
+
# strip in {#apply_attributes!}.
|
|
711
|
+
return with_authdata_trust { Parse::User.build body.merge(response.result) }
|
|
552
712
|
end
|
|
553
713
|
|
|
554
714
|
case response.code
|
|
@@ -620,6 +780,23 @@ module Parse
|
|
|
620
780
|
self.create(body)
|
|
621
781
|
end
|
|
622
782
|
|
|
783
|
+
# Create and log in a new anonymous user via the
|
|
784
|
+
# +authData.anonymous+ provider. The returned user instance has a
|
|
785
|
+
# +session_token+ and an objectId, and {#anonymous?} returns true.
|
|
786
|
+
# Later, after the user has chosen a username and password, upgrade
|
|
787
|
+
# the account in-place with {#upgrade_anonymous!}.
|
|
788
|
+
#
|
|
789
|
+
# Parse Server requires the anonymous-provider payload to include a
|
|
790
|
+
# client-generated +id+; this helper produces one via
|
|
791
|
+
# +SecureRandom.uuid+ so callers don't have to hand-roll the
|
|
792
|
+
# +authData+ shape.
|
|
793
|
+
#
|
|
794
|
+
# @return [User] a freshly-created, logged-in anonymous user.
|
|
795
|
+
# @see #upgrade_anonymous!
|
|
796
|
+
def self.anonymous_signup
|
|
797
|
+
autologin_service(:anonymous, { id: SecureRandom.uuid })
|
|
798
|
+
end
|
|
799
|
+
|
|
623
800
|
# This method will signup a new user using the parameters below. The required fields
|
|
624
801
|
# to create a user in Parse is the _username_ and _password_ fields. The _email_ field is optional.
|
|
625
802
|
# Both _username_ and _email_ (if provided), must be unique. At a minimum, it is recommended you perform
|
|
@@ -636,9 +813,38 @@ module Parse
|
|
|
636
813
|
# @param username [String] the user's username
|
|
637
814
|
# @param password [String] the user's password
|
|
638
815
|
# @return [User] a logged in user for the provided username. Returns nil otherwise.
|
|
816
|
+
# @see .login!
|
|
639
817
|
def self.login(username, password)
|
|
640
818
|
response = client.login(username.to_s, password.to_s)
|
|
641
|
-
response.success?
|
|
819
|
+
return nil unless response.success?
|
|
820
|
+
# Self-fetch trust: the login response IS the authenticating user;
|
|
821
|
+
# any returned authData belongs to them.
|
|
822
|
+
with_authdata_trust { Parse::User.build(response.result) }
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# Login and return a Parse::User with this username/password combination,
|
|
826
|
+
# raising on failure instead of returning nil. Mirrors the
|
|
827
|
+
# `find_by_username!` / `find!` conventions: callers who treat an
|
|
828
|
+
# unsuccessful login as an exceptional condition shouldn't have to
|
|
829
|
+
# build their own `raise if .nil?` boilerplate around every call site.
|
|
830
|
+
#
|
|
831
|
+
# @param username [String] the user's username.
|
|
832
|
+
# @param password [String] the user's password.
|
|
833
|
+
# @return [User] the logged-in user.
|
|
834
|
+
# @raise [Parse::Error::AuthenticationError] when Parse Server rejects
|
|
835
|
+
# the credentials, the request is rate-limited at the server, or the
|
|
836
|
+
# response is otherwise unsuccessful.
|
|
837
|
+
# @see .login
|
|
838
|
+
def self.login!(username, password)
|
|
839
|
+
response = client.login(username.to_s, password.to_s)
|
|
840
|
+
if response.success?
|
|
841
|
+
# Self-fetch trust: see {.login}.
|
|
842
|
+
with_authdata_trust { Parse::User.build(response.result) }
|
|
843
|
+
else
|
|
844
|
+
raise Parse::Error::AuthenticationError,
|
|
845
|
+
"Parse::User.login! failed for #{username.inspect}: " \
|
|
846
|
+
"#{response.error || "HTTP #{response.http_status}"} (code=#{response.code.inspect})"
|
|
847
|
+
end
|
|
642
848
|
end
|
|
643
849
|
|
|
644
850
|
# Request a password reset for a registered email.
|
|
@@ -669,13 +875,50 @@ module Parse
|
|
|
669
875
|
|
|
670
876
|
# Return a Parse::User for this active session token.
|
|
671
877
|
# @raise [InvalidSessionTokenError] Invalid session token.
|
|
878
|
+
# @raise [ArgumentError] when `opts` smuggles a conflicting
|
|
879
|
+
# `:session_token` key — the positional `token` argument is the
|
|
880
|
+
# only source of truth; rejecting the kwarg prevents a silent
|
|
881
|
+
# override that would authenticate as a different user.
|
|
672
882
|
# @return [User] the user matching this active token
|
|
673
883
|
# @see #session
|
|
674
884
|
def self.session!(token, opts = {})
|
|
885
|
+
if opts.is_a?(Hash) && (opts.key?(:session_token) || opts.key?("session_token"))
|
|
886
|
+
raise ArgumentError,
|
|
887
|
+
"Parse::User.session! takes the session token as its positional " \
|
|
888
|
+
"argument; do not also pass it via opts[:session_token]"
|
|
889
|
+
end
|
|
675
890
|
# support Parse::Session objects
|
|
676
891
|
token = token.session_token if token.respond_to?(:session_token)
|
|
677
892
|
response = client.current_user(token, **opts)
|
|
678
|
-
response.success?
|
|
893
|
+
return nil unless response.success?
|
|
894
|
+
# Self-fetch trust: `/users/me` returns the row owned by the
|
|
895
|
+
# supplied session token, so authData here is that user's own.
|
|
896
|
+
with_authdata_trust { Parse::User.build(response.result) }
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
# Block-scoped sugar around {Parse.with_session}: runs the block
|
|
900
|
+
# with this user's `session_token` as the ambient session token for
|
|
901
|
+
# the current fiber. Every Parse call inside the block that doesn't
|
|
902
|
+
# explicitly pass `session_token:` or `use_master_key: true` will be
|
|
903
|
+
# sent as this user.
|
|
904
|
+
# @yield runs the block with the user's session in ambient scope.
|
|
905
|
+
# @return [Object] the block's return value.
|
|
906
|
+
# @raise [Parse::Error::AuthenticationError] when the user has no
|
|
907
|
+
# session token attached.
|
|
908
|
+
# @example
|
|
909
|
+
# user = Parse::User.login!("alice", "pw")
|
|
910
|
+
# user.with_session do
|
|
911
|
+
# Post.all # scoped to alice
|
|
912
|
+
# post.save # scoped to alice
|
|
913
|
+
# end
|
|
914
|
+
def with_session(&block)
|
|
915
|
+
raise ArgumentError, "Parse::User#with_session requires a block" unless block_given?
|
|
916
|
+
unless @session_token.is_a?(String) && !@session_token.empty?
|
|
917
|
+
raise Parse::Error::AuthenticationError,
|
|
918
|
+
"Parse::User#with_session requires an authenticated session — " \
|
|
919
|
+
"obtain the instance via login/signup or call `user.session_token = '...'` first"
|
|
920
|
+
end
|
|
921
|
+
Parse.with_session(@session_token, &block)
|
|
679
922
|
end
|
|
680
923
|
|
|
681
924
|
# If the current session token for this instance is nil, this method finds
|
|
@@ -697,8 +940,17 @@ module Parse
|
|
|
697
940
|
|
|
698
941
|
# Logout from all sessions, effectively signing out on all devices.
|
|
699
942
|
# Optionally keep the current session active.
|
|
943
|
+
#
|
|
944
|
+
# **Self-guard.** Requires the user instance to carry a session token —
|
|
945
|
+
# i.e. to have been obtained via login/signup or attached via
|
|
946
|
+
# {#session_token=}. Without this, `user.id = victim_id;
|
|
947
|
+
# user.logout_all!` could revoke another user's sessions if the
|
|
948
|
+
# deployment has loose `_Session` write CLP. The guard fails closed
|
|
949
|
+
# in the SDK so the deployment's CLP isn't the only line of defense.
|
|
950
|
+
#
|
|
700
951
|
# @param keep_current [Boolean] if true, keeps the current session active (default: false)
|
|
701
952
|
# @return [Integer] the number of sessions revoked
|
|
953
|
+
# @raise [Parse::Error::AuthenticationError] if the user has no session token
|
|
702
954
|
# @example
|
|
703
955
|
# # Logout from all devices
|
|
704
956
|
# user.logout_all!
|
|
@@ -707,32 +959,63 @@ module Parse
|
|
|
707
959
|
# user.logout_all!(keep_current: true)
|
|
708
960
|
def logout_all!(keep_current: false)
|
|
709
961
|
return 0 unless id.present?
|
|
710
|
-
|
|
711
|
-
|
|
962
|
+
require_self_session!("logout_all!")
|
|
963
|
+
current_token = @session_token
|
|
964
|
+
# Self-scope the _Session query: in client mode the ambient client
|
|
965
|
+
# has no auth, so the query must carry this user's session token to
|
|
966
|
+
# be authorized against /classes/_Session. Master-key mode ignores
|
|
967
|
+
# the ambient since the master key still wins.
|
|
968
|
+
count = Parse.with_session(current_token) do
|
|
969
|
+
# Always revoke the OTHER sessions first under the live token —
|
|
970
|
+
# destroying the calling session mid-loop invalidates the token
|
|
971
|
+
# and the remaining deletes 401. Then, if not keeping current,
|
|
972
|
+
# close the calling session via the dedicated logout endpoint.
|
|
973
|
+
n = Parse::Session.revoke_all_for_user(self, except: current_token)
|
|
974
|
+
unless keep_current
|
|
975
|
+
begin
|
|
976
|
+
Parse.client.logout(current_token)
|
|
977
|
+
n += 1
|
|
978
|
+
rescue Parse::Error::InvalidSessionTokenError
|
|
979
|
+
# The calling session was already gone (server-side TTL or
|
|
980
|
+
# concurrent revoke). Idempotent: count what we destroyed.
|
|
981
|
+
end
|
|
982
|
+
end
|
|
983
|
+
n
|
|
984
|
+
end
|
|
712
985
|
@session_token = nil unless keep_current
|
|
713
986
|
@session = nil unless keep_current
|
|
714
987
|
count
|
|
715
988
|
end
|
|
716
989
|
|
|
717
990
|
# Get the count of active (non-expired) sessions for this user.
|
|
991
|
+
# Requires an authenticated session (see {#logout_all!} for the rationale).
|
|
718
992
|
# @return [Integer] the number of active sessions
|
|
993
|
+
# @raise [Parse::Error::AuthenticationError] if the user has no session token
|
|
719
994
|
# @example
|
|
720
995
|
# count = user.active_session_count
|
|
721
996
|
# puts "User is logged in on #{count} devices"
|
|
722
997
|
def active_session_count
|
|
723
998
|
return 0 unless id.present?
|
|
724
|
-
|
|
999
|
+
require_self_session!("active_session_count")
|
|
1000
|
+
Parse.with_session(@session_token) do
|
|
1001
|
+
Parse::Session.active_count_for_user(self)
|
|
1002
|
+
end
|
|
725
1003
|
end
|
|
726
1004
|
|
|
727
1005
|
# Get all active sessions for this user.
|
|
1006
|
+
# Requires an authenticated session (see {#logout_all!} for the rationale).
|
|
728
1007
|
# @return [Array<Parse::Session>] array of active session objects
|
|
1008
|
+
# @raise [Parse::Error::AuthenticationError] if the user has no session token
|
|
729
1009
|
# @example
|
|
730
1010
|
# user.sessions.each do |session|
|
|
731
1011
|
# puts "Session created: #{session.created_at}"
|
|
732
1012
|
# end
|
|
733
1013
|
def sessions
|
|
734
1014
|
return [] unless id.present?
|
|
735
|
-
|
|
1015
|
+
require_self_session!("sessions")
|
|
1016
|
+
Parse.with_session(@session_token) do
|
|
1017
|
+
Parse::Session.for_user(self).all
|
|
1018
|
+
end
|
|
736
1019
|
end
|
|
737
1020
|
|
|
738
1021
|
# Check if this user has multiple active sessions (logged in on multiple devices).
|
|
@@ -811,6 +1094,19 @@ module Parse
|
|
|
811
1094
|
|
|
812
1095
|
private
|
|
813
1096
|
|
|
1097
|
+
# Self-guard for session-scoped instance methods. Fails closed when
|
|
1098
|
+
# the user instance carries no `@session_token`, preventing the
|
|
1099
|
+
# `Parse::User.new.tap { |u| u.id = victim_id }` attack on any
|
|
1100
|
+
# method that derives its authorization from the user's identity
|
|
1101
|
+
# alone. See {#logout_all!} for the full rationale.
|
|
1102
|
+
# @raise [Parse::Error::AuthenticationError] when no session token is attached.
|
|
1103
|
+
def require_self_session!(method_name)
|
|
1104
|
+
return if @session_token.is_a?(String) && !@session_token.empty?
|
|
1105
|
+
raise Parse::Error::AuthenticationError,
|
|
1106
|
+
"Parse::User##{method_name} requires an authenticated session — " \
|
|
1107
|
+
"obtain the instance via login/signup or call `user.session_token = '...'` first"
|
|
1108
|
+
end
|
|
1109
|
+
|
|
814
1110
|
# Keys that {#signup_create} will accept from a `POST /parse/users`
|
|
815
1111
|
# response body and feed through {#set_attributes!}. `sessionToken`
|
|
816
1112
|
# is the operative output of the signup endpoint; `emailVerified` is
|
data/lib/parse/model/clp.rb
CHANGED
|
@@ -104,7 +104,7 @@ module Parse
|
|
|
104
104
|
# @param fields [Array<String, Symbol>] pointer field names
|
|
105
105
|
# @return [self]
|
|
106
106
|
# @example
|
|
107
|
-
# clp.set_read_user_fields(:owner, :
|
|
107
|
+
# clp.set_read_user_fields(:owner, :coauthors)
|
|
108
108
|
def set_read_user_fields(*fields)
|
|
109
109
|
@permissions[:readUserFields] = fields.flatten.map(&:to_s)
|
|
110
110
|
self
|
|
@@ -255,6 +255,13 @@ module Parse
|
|
|
255
255
|
|
|
256
256
|
def degraded_store?(store)
|
|
257
257
|
return true if store.nil?
|
|
258
|
+
# The Parse::Cache::Redis wrapper (and its Pool) are known
|
|
259
|
+
# cross-process stores even though they don't expose a Moneta
|
|
260
|
+
# `.adapter` chain to walk. Anything that can't forward `#create`
|
|
261
|
+
# cannot serve as a lock store, so fall back to the process-local
|
|
262
|
+
# path rather than spinning until timeout on NoMethodError.
|
|
263
|
+
return false if defined?(Parse::Cache::Redis) && store.is_a?(Parse::Cache::Redis)
|
|
264
|
+
return true unless store.respond_to?(:create)
|
|
258
265
|
bottom = walk_to_adapter(store)
|
|
259
266
|
return true if bottom.nil?
|
|
260
267
|
klass_name = bottom.class.name.to_s
|
|
@@ -366,8 +373,13 @@ module Parse
|
|
|
366
373
|
@plain_sha_warned = true
|
|
367
374
|
warn "[Parse::CreateLock:SECURITY] No PARSE_STACK_LOCK_SECRET configured and Redis-backed store detected. " \
|
|
368
375
|
"Falling back to plain SHA256 for lock-key derivation so cross-process locking actually works. " \
|
|
369
|
-
"
|
|
370
|
-
"
|
|
376
|
+
"Risks of running without an HMAC secret: (1) lock keys are deterministic and may expose query_attrs " \
|
|
377
|
+
"content via Redis MONITOR/snapshots; (2) when the response cache and the lock store share a Redis DB, " \
|
|
378
|
+
"any caller with write access to Parse.cache can plant a `parse-stack:foc:v1:<sha>` key under a guessable " \
|
|
379
|
+
"digest of (app_id, class, principal, query_attrs) and suppress first_or_create!/create_or_update! for " \
|
|
380
|
+
"that tuple until TTL expiry — a targeted DoS / create-pinning primitive. " \
|
|
381
|
+
"Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') to enable HMAC keying, or " \
|
|
382
|
+
"point Parse.synchronize_create_store at a separate Redis DB from the response cache."
|
|
371
383
|
end
|
|
372
384
|
|
|
373
385
|
def instrument(event, key, payload = {})
|