parse-stack-next 5.7.6 → 5.8.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -34,10 +34,12 @@ module Parse
34
34
  ATTRIBUTES
35
35
  end
36
36
 
37
- # Base64 encode and set the instance contents
37
+ # Base64 encode and set the instance contents. Uses strict (RFC 4648)
38
+ # encoding: `Base64.encode64` inserts a newline every 60 characters,
39
+ # which Parse Server stores verbatim in the Bytes column.
38
40
  # @param str the string to encode
39
41
  def encode(str)
40
- @base64 = Base64.encode64(str)
42
+ @base64 = Base64.strict_encode64(str.to_s)
41
43
  end
42
44
 
43
45
  # Get the content as decoded base64 bytes
@@ -45,11 +47,17 @@ module Parse
45
47
  Base64.decode64(@base64 || "")
46
48
  end
47
49
 
50
+ # Mass-assign the base64 content from a base64 String, a Parse Bytes
51
+ # hash (`{"__type" => "Bytes", "base64" => "..."}`), or another
52
+ # {Parse::Bytes}.
53
+ # @param a [String, Hash, Parse::Bytes]
48
54
  def attributes=(a)
49
- if a.is_a?(String)
50
- @bytes = a
55
+ if a.is_a?(Bytes)
56
+ @base64 = a.base64.dup
57
+ elsif a.is_a?(String)
58
+ @base64 = a.dup
51
59
  elsif a.is_a?(Hash)
52
- @bytes = a["base64"] || @bytes
60
+ @base64 = a["base64"] || a[:base64] || @base64
53
61
  end
54
62
  end
55
63
 
@@ -62,6 +62,78 @@ module Parse
62
62
  # @return [RelationCollectionProxy<User>] a Parse relation of users belonging to this role.
63
63
  has_many :users, through: :relation
64
64
 
65
+ # Grants and revocations reach mongo-direct ACL resolution immediately
66
+ # rather than after the role-cache TTL: the users whose membership a save
67
+ # changes have their cached role closures dropped, and a change to the
68
+ # role hierarchy drops them all.
69
+ before_save :_capture_role_membership_changes
70
+ after_save :_invalidate_role_caches
71
+
72
+ # Atomic relation operations (`role.users.add!`, `role.roles.remove!`)
73
+ # write immediately without a save, so they invalidate the same caches.
74
+ # @!visibility private
75
+ def op_add_relation!(field, objects = [])
76
+ result = super
77
+ _invalidate_after_relation_op(field, objects) if result
78
+ result
79
+ end
80
+
81
+ # @!visibility private
82
+ def op_remove_relation!(field, objects = [])
83
+ result = super
84
+ _invalidate_after_relation_op(field, objects) if result
85
+ result
86
+ end
87
+
88
+ # @!visibility private
89
+ def _invalidate_after_relation_op(field, objects)
90
+ key = field.to_s
91
+ if %w[roles].include?(key)
92
+ @_role_hierarchy_changed = true
93
+ elsif %w[users].include?(key)
94
+ @_role_membership_changed_ids = Array(objects).filter_map do |u|
95
+ id = u.respond_to?(:id) ? u.id : (u.is_a?(Hash) ? (u["objectId"] || u[:objectId]) : u)
96
+ id.to_s if id.present?
97
+ end
98
+ else
99
+ return
100
+ end
101
+ _invalidate_role_caches
102
+ end
103
+
104
+ # @!visibility private
105
+ def _capture_role_membership_changes
106
+ users_proxy = instance_variable_get(:@users)
107
+ ids = []
108
+ if users_proxy.respond_to?(:additions)
109
+ (Array(users_proxy.additions) + Array(users_proxy.removals)).each do |u|
110
+ id = u.respond_to?(:id) ? u.id : u
111
+ ids << id.to_s if id.present?
112
+ end
113
+ end
114
+ roles_proxy = instance_variable_get(:@roles)
115
+ @_role_hierarchy_changed = roles_proxy.respond_to?(:additions) &&
116
+ (Array(roles_proxy.additions).any? || Array(roles_proxy.removals).any?)
117
+ @_role_membership_changed_ids = ids.uniq
118
+ nil
119
+ end
120
+
121
+ # @!visibility private
122
+ def _invalidate_role_caches
123
+ auth = client.respond_to?(:authorization) ? client.authorization : nil
124
+ return unless auth
125
+ if @_role_hierarchy_changed && auth.respond_to?(:invalidate_all_roles)
126
+ auth.invalidate_all_roles
127
+ elsif auth.respond_to?(:invalidate_user_roles)
128
+ Array(@_role_membership_changed_ids).each { |id| auth.invalidate_user_roles(id) }
129
+ end
130
+ rescue StandardError => e
131
+ warn "[Parse::Role] role cache invalidation failed: #{e.class}"
132
+ ensure
133
+ @_role_membership_changed_ids = nil
134
+ @_role_hierarchy_changed = nil
135
+ end
136
+
65
137
  # Names of Mongo driver errors that mean "the server is momentarily
66
138
  # unreachable", for which falling back to the Parse Server walk is right.
67
139
  #
@@ -207,6 +279,12 @@ module Parse
207
279
  # query/constraints) — none of those have a caller scope to
208
280
  # forward. The fast path is opt-in for performance-conscious
209
281
  # callers that can supply explicit authorization.
282
+ # @note Inside {Parse.without_master_key}, the Parse Server walk (no
283
+ # `master:` or `as:`) still reads `_Role` with the master key as SDK
284
+ # metadata, so it returns the full role-name closure of `user`, not
285
+ # only the publicly readable roles. The block guards against
286
+ # accidental master-key use; it does not scope this lookup. Pass
287
+ # `as:` for a scope-checked answer.
210
288
  # @example
211
289
  # names = Parse::Role.all_for_user(user, master: true) # admin/analytics
212
290
  # names = Parse::Role.all_for_user(user, as: current_user) # scope-checked
@@ -351,6 +429,20 @@ module Parse
351
429
  end
352
430
 
353
431
  def role_query_all(constraints, client: nil)
432
+ # Inside `Parse.without_master_key` the role graph is still read with
433
+ # the master key. It is metadata the SDK needs to enforce a scope, as
434
+ # Parse Server reads it itself; read without the key it returned only
435
+ # publicly readable roles, and that short closure was then cached for
436
+ # the user and served to every caller until it expired.
437
+ if Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
438
+ query = Parse::Role.query(constraints.reverse_merge(limit: :max))
439
+ query.client = client if client
440
+ # A master-key read: keep it out of the shared response cache.
441
+ query.cache = false
442
+ query.instance_variable_set(:@_metadata_master, true)
443
+ return query.results
444
+ end
445
+
354
446
  # No explicit client means the historical path, unchanged. This is not
355
447
  # only for compatibility: `Parse::Role.all` is what callers and tests
356
448
  # observe and stub, and routing around it when nothing asked us to
@@ -885,6 +977,9 @@ module Parse
885
977
  # another's role names.
886
978
  # @param strict [Boolean] re-raise role-query failures rather than returning
887
979
  # a partial parent closure.
980
+ # @note Inside {Parse.without_master_key} the walk still reads `_Role`
981
+ # with the master key as SDK metadata, so it returns the full parent
982
+ # closure, not only publicly readable roles.
888
983
  def all_parent_role_names(max_depth: 10, client: nil, strict: false)
889
984
  Parse::Role.expand_inheritance_upward(
890
985
  [self], max_depth: max_depth, client: client, strict: strict,
@@ -90,7 +90,9 @@ module Parse
90
90
  opts.delete(:session_token)
91
91
  response = client.fetch_session(token, **opts)
92
92
  if response.success?
93
- return Parse::Session.build response.result
93
+ built = Parse::Session.build response.result
94
+ _remember_owners!([built], client)
95
+ return built
94
96
  end
95
97
  nil
96
98
  end
@@ -204,5 +206,306 @@ module Parse
204
206
  def revoke!
205
207
  destroy
206
208
  end
209
+
210
+ # Deletes the session and forgets it in the client's identity plane, so
211
+ # mongo-direct and Atlas Search reads stop resolving the token at once
212
+ # instead of when the cached entry expires. Both the token and the owning
213
+ # user's entries are dropped. A session that does not carry them (built
214
+ # from its objectId, or fetched without the master key or with `keys:`
215
+ # leaving them out) has them looked up first: with the master key as SDK
216
+ # metadata when the client has one, otherwise with the `session:` passed
217
+ # here. A lookup that finds no row leaves nothing to forget. Only a
218
+ # lookup that fails outright drops every cached identity on the client,
219
+ # at most once every {RESET_INTERVAL} seconds per client.
220
+ #
221
+ # The entries are dropped when the delete succeeds or reports the row
222
+ # is already gone (or not visible to the caller). A delete that raises
223
+ # still drops the known token, which is harmless, but does not touch the
224
+ # owner's entries.
225
+ #
226
+ # A batch delete through `Array#destroy` drops the same entries for
227
+ # each session in it. With an identity plane that cannot drop a
228
+ # single user's entries (a custom plane without `bump_generation` or
229
+ # `invalidate_value`), a revoked token keeps resolving until its cached
230
+ # entry expires.
231
+ # @param session [String] (see Parse::Object#destroy)
232
+ # @return [Boolean] whether the operation was successful.
233
+ def destroy(session: nil)
234
+ # A session never saved is not deleted (see Parse::Object#destroy), so
235
+ # there is nothing to forget either.
236
+ return super if new?
237
+ token = nil
238
+ owner_id = nil
239
+ begin
240
+ Parse::Session.send(:_preload_identity_for_destroy!, [self], session_token: session)
241
+ token, owner_id = _identity_for_destroy
242
+ ensure
243
+ # The looked-up token is a live credential: keep it in locals only.
244
+ _clear_identity_for_destroy!
245
+ end
246
+ result = nil
247
+ begin
248
+ result = super
249
+ ensure
250
+ # `false` covers "object not found", which is either a session
251
+ # already revoked elsewhere or one the caller cannot see; dropping
252
+ # cached entries is idempotent in both cases. A raised delete still
253
+ # drops the token it named but leaves the owner alone. A single
254
+ # delete cannot tell "already gone" from "denied", so an absent row
255
+ # only uses the recorded owner, never the reset fallback.
256
+ if result.nil?
257
+ _forget_identity!(token.is_a?(String) ? token : nil, nil)
258
+ else
259
+ _forget_identity!(token, owner_id, reset_fallback: false)
260
+ end
261
+ end
262
+ end
263
+
264
+ # Seconds between two full identity-cache resets triggered by a failed
265
+ # session lookup on the same client.
266
+ RESET_INTERVAL = 5
267
+
268
+ # Process-local record of the last lookup-triggered reset per client.
269
+ @identity_reset_at = {}
270
+ @identity_reset_mutex = Mutex.new
271
+
272
+ class << self
273
+ # Record, in the identity context of `cl` (the client that fetched
274
+ # them), which user owns each session that carries both its objectId
275
+ # and its owner, so a later delete can drop the owner's cached
276
+ # identities after the row is gone. Never raises: it runs while query
277
+ # results are decoded.
278
+ # @param sessions [Array<Parse::Object>]
279
+ # @param cl [Parse::Client]
280
+ # @!visibility private
281
+ def _remember_owners!(sessions, cl)
282
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
283
+ return unless auth.respond_to?(:remember_session_owner)
284
+ Array(sessions).each do |o|
285
+ next unless o.is_a?(Parse::Session)
286
+ owner_id = o.send(:_identity_owner_id)
287
+ id = o.id
288
+ auth.remember_session_owner(id, owner_id) if owner_id && id.present?
289
+ end
290
+ nil
291
+ rescue StandardError
292
+ nil
293
+ end
294
+
295
+ private
296
+
297
+ # Reset `cl`'s identity and role caches, at most once every
298
+ # {RESET_INTERVAL} seconds per client.
299
+ # @!visibility private
300
+ def _rate_limited_identity_reset!(cl)
301
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
302
+ return unless auth.respond_to?(:reset_caches!)
303
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
304
+ due = @identity_reset_mutex.synchronize do
305
+ last = @identity_reset_at[cl.object_id]
306
+ next false if last && now - last < RESET_INTERVAL
307
+ @identity_reset_at[cl.object_id] = now
308
+ true
309
+ end
310
+ auth.reset_caches! if due
311
+ end
312
+
313
+ # Look up the token and owner of every session about to be deleted that
314
+ # does not carry them, so the delete can drop their identity entries.
315
+ # One `_Session` query per client. A client with a master key reads it
316
+ # as SDK metadata (it works inside `Parse.without_master_key`); one
317
+ # without reads it with `session_token`, or skips the lookup when there
318
+ # is none. The query never uses the response cache: its rows carry live
319
+ # session tokens.
320
+ #
321
+ # A row the lookup did not return is marked absent: there is nothing
322
+ # to forget for it. Only a lookup that raised marks its sessions
323
+ # `:unknown`, which makes their delete reset the client's identity
324
+ # cache (rate limited).
325
+ # @param sessions [Array<Parse::Object>]
326
+ # @param session_token [String, nil] the delete's own session.
327
+ # @!visibility private
328
+ def _preload_identity_for_destroy!(sessions, session_token: nil)
329
+ pending = sessions.select { |o| o.is_a?(Parse::Session) && o.send(:_identity_lookup_needed?) }
330
+ return if pending.empty?
331
+ pending.group_by(&:client).each do |cl, group|
332
+ has_master = cl.respond_to?(:master_key) && cl.master_key.present?
333
+ token = session_token.respond_to?(:session_token) ? session_token.session_token : session_token
334
+ token = nil unless token.is_a?(String) && !token.strip.empty?
335
+ unless has_master || token
336
+ group.each { |o| o.instance_variable_set(:@_identity_for_destroy, :absent) }
337
+ next
338
+ end
339
+ ids = group.map(&:id).uniq
340
+ found = begin
341
+ query = Parse::Session.query(:objectId.in => ids, limit: ids.size)
342
+ query.keys(:session_token, :user)
343
+ query.client = cl
344
+ query.cache = false
345
+ if has_master
346
+ query.instance_variable_set(:@_metadata_master, true)
347
+ else
348
+ query.session_token = token
349
+ end
350
+ query.results.to_h do |row|
351
+ owner = row.instance_variable_get(:@user)
352
+ [row.id, [row.instance_variable_get(:@session_token), owner.respond_to?(:id) ? owner.id : nil]]
353
+ end
354
+ rescue StandardError => e
355
+ warn "[Parse::Session] could not look up the token and owner of " \
356
+ "#{ids.size} session(s) before deleting them (#{e.class}); " \
357
+ "dropping the whole identity cache instead."
358
+ nil
359
+ end
360
+ group.each do |o|
361
+ mark = if found.nil?
362
+ :unknown
363
+ elsif (row = found[o.id]) && (row[0].is_a?(String) || row[1])
364
+ row
365
+ else
366
+ :absent
367
+ end
368
+ o.instance_variable_set(:@_identity_for_destroy, mark)
369
+ end
370
+ end
371
+ end
372
+ end
373
+
374
+ # Called by `Array#destroy` for each session in the batch with its
375
+ # response. The entries are dropped only when the delete succeeded or
376
+ # reported the row gone ("object not found").
377
+ # @!visibility private
378
+ def _after_batch_destroy(response = nil)
379
+ identity = _identity_for_destroy
380
+ _clear_identity_for_destroy!
381
+ return unless Parse::Session.send(:_destroy_applied?, response)
382
+ # The batch response tells success and "object not found" apart from
383
+ # a denial, so an absent row whose owner was never recorded may fall
384
+ # back to the rate-limited reset here.
385
+ _forget_identity!(*identity, reset_fallback: true)
386
+ end
387
+ private :_after_batch_destroy
388
+
389
+ # Whether a batch delete response means the row is gone: success, or
390
+ # "object not found". A missing response (older callers) counts as
391
+ # applied.
392
+ # @!visibility private
393
+ def self._destroy_applied?(response)
394
+ return true if response.nil?
395
+ (response.respond_to?(:success?) && response.success?) ||
396
+ (response.respond_to?(:object_not_found?) && response.object_not_found?)
397
+ end
398
+ private_class_method :_destroy_applied?
399
+
400
+ # Remove the token and owner {._preload_identity_for_destroy!} looked
401
+ # up. The token is a live credential and must not outlive the delete
402
+ # (it would show in `inspect` or `instance_variables`).
403
+ # @!visibility private
404
+ def _clear_identity_for_destroy!
405
+ remove_instance_variable(:@_identity_for_destroy) if instance_variable_defined?(:@_identity_for_destroy)
406
+ end
407
+ private :_clear_identity_for_destroy!
408
+
409
+ # Whether this session lacks the token or the owner its identity-plane
410
+ # entries are keyed by: a fetch without the master key (no token) or
411
+ # with `keys:` leaving them out.
412
+ # @!visibility private
413
+ def _identity_lookup_needed?
414
+ # Keyed on the objectId, not `new?`: `Array#destroy` deletes by id
415
+ # alone, so a session built from an id (no timestamps loaded) is still
416
+ # deleted and its token must still be dropped.
417
+ return false if @id.blank?
418
+ !@session_token.is_a?(String) || @session_token.empty? || _identity_owner_id.nil?
419
+ end
420
+ private :_identity_lookup_needed?
421
+
422
+ # Serialization omits `sessionToken` unless `include_session_token: true`
423
+ # is passed. A session token is a bearer credential, and `as_json` is the
424
+ # surface that reaches logs, API responses and agent tool output.
425
+ # @param opts [Hash] see Parse::Object#as_json.
426
+ # @option opts [Boolean] :include_session_token include the token.
427
+ # @return [Hash]
428
+ def as_json(opts = nil)
429
+ opts = (opts || {}).dup
430
+ include_token = opts.delete(:include_session_token) == true
431
+ json = super(opts)
432
+ return json if include_token || !json.is_a?(Hash)
433
+ json.except("session_token", "sessionToken", :session_token, :sessionToken)
434
+ end
435
+
436
+ # Redacts the session token from the default inspect output.
437
+ # @return [String]
438
+ def inspect
439
+ looked_up = @_identity_for_destroy
440
+ Parse::User.redact_session_token(super, @session_token, looked_up.is_a?(Array) ? looked_up[0] : nil)
441
+ end
442
+
443
+ private
444
+
445
+ # The token and owner id to forget for this delete: what this instance
446
+ # carries, completed by {._preload_identity_for_destroy!} when it did
447
+ # not, or `:unknown` when that lookup failed.
448
+ # @!visibility private
449
+ def _identity_for_destroy
450
+ looked_up = @_identity_for_destroy
451
+ token = @session_token.is_a?(String) && !@session_token.empty? ? @session_token : nil
452
+ owner_id = _identity_owner_id
453
+ return [:unknown, owner_id] if looked_up == :unknown
454
+ if looked_up == :absent
455
+ # No row to read: the session is gone or not visible. Forget what
456
+ # this instance carries; with nothing at all, mark it absent so the
457
+ # delete can fall back to the recorded owner.
458
+ return token || owner_id ? [token, owner_id] : [:absent, nil]
459
+ end
460
+ if looked_up.is_a?(Array)
461
+ token ||= looked_up[0]
462
+ owner_id ||= looked_up[1]
463
+ end
464
+ [token, owner_id]
465
+ end
466
+
467
+ # The owning user's id, read from the association ivar directly:
468
+ # calling `user` on a partially fetched session could trigger an
469
+ # autofetch just to learn the owner.
470
+ # @!visibility private
471
+ def _identity_owner_id
472
+ owner = @user
473
+ owner.respond_to?(:id) ? owner.id : nil
474
+ end
475
+
476
+ # Drop the token and the owner's entries from this session's client's
477
+ # identity plane.
478
+ # @!visibility private
479
+ def _forget_identity!(token, owner_id, reset_fallback: false)
480
+ cl = client
481
+ if token == :absent
482
+ # The row was not readable before the delete. Use the owner recorded
483
+ # when this session was last loaded; without one, and only when the
484
+ # delete is known to have applied or found the row gone, fall back to
485
+ # the rate-limited reset. Never on a denied delete.
486
+ token = nil
487
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
488
+ recorded = auth.respond_to?(:session_owner) ? auth.session_owner(@id) : nil
489
+ if recorded
490
+ owner_id ||= recorded
491
+ elsif owner_id.nil? && reset_fallback
492
+ Parse::Session.send(:_rate_limited_identity_reset!, cl)
493
+ end
494
+ end
495
+ if token == :unknown
496
+ # The lookup failed, so the token's entry cannot be named. Drop every
497
+ # cached identity on this client (the next read of each token
498
+ # re-resolves it, which is safe), at most once every RESET_INTERVAL
499
+ # seconds so repeated failures cannot flush a shared plane on every
500
+ # request. The owner's entries go as well when the owner is known.
501
+ Parse::Session.send(:_rate_limited_identity_reset!, cl)
502
+ token = nil
503
+ end
504
+ cl.invalidate_session_identity(token) if token.is_a?(String) && cl.respond_to?(:invalidate_session_identity)
505
+ cl.invalidate_user_identity(owner_id) if owner_id && cl.respond_to?(:invalidate_user_identity)
506
+ rescue StandardError
507
+ # Runs from an `ensure`: never replace the delete's own outcome.
508
+ nil
509
+ end
207
510
  end
208
511
  end
@@ -202,6 +202,17 @@ module Parse
202
202
  class User < Parse::Object
203
203
  parse_class Parse::Model::CLASS_USER
204
204
 
205
+ # ActiveModel naming relative to the `Parse` namespace, so Rails forms,
206
+ # params, routes, and i18n see `Parse::User` as `user`
207
+ # (`params[:user]`, `users_path`) rather than `parse_user`. `name` stays
208
+ # "Parse::User". A subclass outside the `Parse` namespace keeps the
209
+ # default naming.
210
+ # @return [ActiveModel::Name]
211
+ def self.model_name
212
+ return super if name.nil? || !name.start_with?("Parse::")
213
+ @_relative_model_name ||= ActiveModel::Name.new(self, Parse)
214
+ end
215
+
205
216
  # When true (default), saving a new {Parse::User} that has a `password`
206
217
  # value routes through Parse Server's signup endpoint (`POST /parse/users`)
207
218
  # with the `X-Parse-Revocable-Session` header set, so the signup response
@@ -701,7 +712,7 @@ module Parse
701
712
 
702
713
  if response.success?
703
714
  result = response.result || {}
704
- @updated_at = result["updatedAt"] || @updated_at
715
+ @updated_at = _server_date(result["updatedAt"]) || @updated_at
705
716
  # Parse Server may rotate the session token on a credential
706
717
  # change; apply it narrowly if present without going through the
707
718
  # full property writer chain.
@@ -815,8 +826,8 @@ module Parse
815
826
  # save-as-signup path had already addressed.
816
827
  result = response.result
817
828
  @id = result[Parse::Model::OBJECT_ID] || @id
818
- @created_at = result["createdAt"] || @created_at
819
- @updated_at = result["updatedAt"] || result["createdAt"] || @updated_at
829
+ @created_at = _server_date(result["createdAt"]) || @created_at
830
+ @updated_at = _server_date(result["updatedAt"] || result["createdAt"]) || @updated_at
820
831
  set_attributes!(result.slice(*SIGNUP_RESPONSE_APPLY_KEYS))
821
832
  # Drop the plaintext password from memory now that the server
822
833
  # has it hashed and we no longer need it. Matches the Parse JS
@@ -908,6 +919,78 @@ module Parse
908
919
  self.session_token.present?
909
920
  end
910
921
 
922
+ # Replace each non-empty token in `text` with `[FILTERED]`. Used by the
923
+ # `inspect` overrides on {Parse::User} and {Parse::Session} so a session
924
+ # token does not land in logs, exception messages or console history.
925
+ # @!visibility private
926
+ # @param text [String] the inspect output.
927
+ # @param tokens [Array<String, nil>] tokens to redact.
928
+ # @return [String]
929
+ def self.redact_session_token(text, *tokens)
930
+ out = text.to_s
931
+ tokens.each do |t|
932
+ next unless t.is_a?(String) && !t.strip.empty?
933
+ out = out.gsub(t, "[FILTERED]")
934
+ end
935
+ out
936
+ end
937
+
938
+ # Default inspect output with the session token redacted.
939
+ # @return [String]
940
+ def inspect
941
+ self.class.redact_session_token(super, @session_token, @_session_token)
942
+ end
943
+
944
+ # Saves pending changes. When the save changes the password, Parse Server
945
+ # revokes the user's other sessions, so this client's identity plane
946
+ # drops every cached entry for the user to make the revocation visible
947
+ # to mongo-direct reads at once.
948
+ # @!visibility private
949
+ def update!(raw: false, force: false)
950
+ password_changing = changed.include?("password")
951
+ result = super
952
+ ok = raw ? (result.respond_to?(:success?) && result.success?) : result == true
953
+ if ok && password_changing && client.respond_to?(:invalidate_user_identity)
954
+ client.invalidate_user_identity(id)
955
+ end
956
+ result
957
+ end
958
+
959
+ # Deletes the user. Every cached identity entry for the user is dropped
960
+ # from this client's identity plane, since Parse Server removes the
961
+ # user's sessions with the account. The entries are dropped when the
962
+ # delete returns, successful or not (a failure is usually "object not
963
+ # found": the account is already gone or not visible to the caller, and
964
+ # dropping cached entries is idempotent). A delete that raises leaves
965
+ # them alone.
966
+ # @param session [String] (see Parse::Object#destroy)
967
+ # @return [Boolean] whether the operation was successful.
968
+ def destroy(session: nil)
969
+ user_id = id
970
+ result = super
971
+ begin
972
+ client.invalidate_user_identity(user_id) if user_id.present? && client.respond_to?(:invalidate_user_identity)
973
+ rescue StandardError
974
+ # Never replace the delete's own outcome.
975
+ nil
976
+ end
977
+ result
978
+ end
979
+
980
+ # Called by `Array#destroy` for each user in the batch with its
981
+ # response. Drops the user's identity entries, as {#destroy} does, when
982
+ # the delete succeeded or the row is already gone; a denied delete
983
+ # leaves them alone.
984
+ # @!visibility private
985
+ def _after_batch_destroy(response = nil)
986
+ applied = response.nil? ||
987
+ (response.respond_to?(:success?) && response.success?) ||
988
+ (response.respond_to?(:object_not_found?) && response.object_not_found?)
989
+ return unless applied
990
+ client.invalidate_user_identity(id) if id.present? && client.respond_to?(:invalidate_user_identity)
991
+ end
992
+ private :_after_batch_destroy
993
+
911
994
  # Invalid the current session token for this logged in user.
912
995
  # @return [Boolean] True/false if successful
913
996
  def logout
@@ -1016,7 +1099,10 @@ module Parse
1016
1099
  # the request.
1017
1100
  #
1018
1101
  # @param body [Hash] The hash containing the Parse::User fields. The field `username` and `password` are required.
1019
- # @option opts [Boolean] :master_key Whether the master key should be used for this request.
1102
+ # @option opts [Boolean] :use_master_key send the master key with the
1103
+ # signup. Off by default: a master-key signup returns no session token
1104
+ # and bypasses the `_User` create CLP. Pass `true` only for admin
1105
+ # provisioning that needs that bypass.
1020
1106
  # @raise [ArgumentError] If `body` contains `authData`/`auth_data`/`objectId` — use {.autologin_service} for federated flows.
1021
1107
  # @raise [Parse::Error::UsernameMissingError] If username is missing.
1022
1108
  # @raise [Parse::Error::PasswordMissingError] If password is missing.
@@ -1145,6 +1231,8 @@ module Parse
1145
1231
  # @param username [String] the user's username
1146
1232
  # @param password [String] the user's password
1147
1233
  # @return [User] a logged in user for the provided username. Returns nil otherwise.
1234
+ # @raise [Parse::MFA::RequiredError] when the account has MFA enabled; use
1235
+ # {.login_with_mfa} with the user's code.
1148
1236
  # @see .login!
1149
1237
  def self.login(username, password)
1150
1238
  response = client.login(username.to_s, password.to_s)
@@ -1350,6 +1438,13 @@ module Parse
1350
1438
  end
1351
1439
  n
1352
1440
  end
1441
+ # Every session but (optionally) the current one is gone server-side.
1442
+ # Drop the user's identity entries on this client and on the default
1443
+ # client (the logout above went through `Parse.client`). A kept
1444
+ # current token simply re-resolves on its next use.
1445
+ [client, Parse.client].uniq.each do |cl|
1446
+ cl.invalidate_user_identity(id) if cl.respond_to?(:invalidate_user_identity)
1447
+ end
1353
1448
  @session_token = nil unless keep_current
1354
1449
  @session = nil unless keep_current
1355
1450
  count
@@ -1648,14 +1743,15 @@ module Parse
1648
1743
  # Cloud Code `beforeSave(_User)` see `request.user = caller`,
1649
1744
  # which an integrator can mistake for "the new user". The signup
1650
1745
  # endpoint authenticates by the signup itself, not by a prior
1651
- # session — pass `nil` explicitly. Master key continues to flow
1652
- # via the normal authentication middleware when configured.
1746
+ # session, so pass `nil` explicitly. The master key is not sent
1747
+ # either (see Parse::API::Users#create_user): Parse Server mints no
1748
+ # session token for a master-key signup.
1653
1749
  res = client.create_user(body, session_token: nil)
1654
1750
  unless res.error?
1655
1751
  result = res.result
1656
1752
  @id = result[Parse::Model::OBJECT_ID] || @id
1657
- @created_at = result["createdAt"] || @created_at
1658
- @updated_at = result["updatedAt"] || result["createdAt"] || @updated_at
1753
+ @created_at = _server_date(result["createdAt"]) || @created_at
1754
+ @updated_at = _server_date(result["updatedAt"] || result["createdAt"]) || @updated_at
1659
1755
  # Plaintext password is no longer needed locally; the server
1660
1756
  # has it hashed. Direct ivar assignment avoids re-dirtying the
1661
1757
  # field.