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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
@@ -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 EXPERIMENTAL: This feature is not fully implemented. The WebSocket client
11
- # is incomplete. You must explicitly enable this feature before use:
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 enabled
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 is experimental and must be explicitly enabled. Set Parse.live_query_enabled = true")
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
- # team.members.as_json
321
+ # workspace.members.as_json
322
322
  # # => [{"objectId"=>"abc", "name"=>"Alice", ...}, ...]
323
323
  # @example Pointers only for storage
324
- # team.members.as_json(pointers_only: true)
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
- # capture.assets.as_json
110
- # # => [{"__type"=>"Pointer", "className"=>"Asset", "objectId"=>"abc"}, ...]
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
- # capture.assets.as_json(pointers_only: false)
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: opts)
704
+ response = client.create_user(body, **opts)
549
705
  if response.success?
550
706
  body.delete :password # clear password before merging
551
- return Parse::User.build body.merge(response.result)
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? ? Parse::User.build(response.result) : nil
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? ? Parse::User.build(response.result) : nil
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
- except_token = keep_current ? @session_token : nil
711
- count = Parse::Session.revoke_all_for_user(self, except: except_token)
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
- Parse::Session.active_count_for_user(self)
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
- Parse::Session.for_user(self).all
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
@@ -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, :collaborators)
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
- "Lock keys are deterministic and may expose query_attrs content via Redis MONITOR/snapshots. " \
370
- "Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') to enable HMAC keying."
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 = {})