parse-stack-next 5.8.0 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +318 -0
  3. data/README.md +25 -19
  4. data/docs/caching.md +50 -0
  5. data/docs/mcp_guide.md +15 -3
  6. data/docs/webhooks_guide.md +59 -7
  7. data/lib/parse/acl_scope.rb +71 -9
  8. data/lib/parse/agent/describe.rb +8 -2
  9. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  10. data/lib/parse/agent.rb +205 -20
  11. data/lib/parse/atlas_search/index_manager.rb +5 -1
  12. data/lib/parse/atlas_search.rb +49 -1
  13. data/lib/parse/authorization.rb +235 -3
  14. data/lib/parse/cache/sub_cache.rb +26 -0
  15. data/lib/parse/client/authentication.rb +19 -1
  16. data/lib/parse/client/batch.rb +35 -2
  17. data/lib/parse/client.rb +26 -2
  18. data/lib/parse/clp_scope.rb +37 -3
  19. data/lib/parse/live_query/client.rb +72 -2
  20. data/lib/parse/lock_backend.rb +4 -3
  21. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  22. data/lib/parse/model/associations/has_many.rb +11 -0
  23. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  24. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  25. data/lib/parse/model/classes/role.rb +23 -0
  26. data/lib/parse/model/classes/session.rb +279 -19
  27. data/lib/parse/model/classes/user.rb +33 -12
  28. data/lib/parse/model/core/actions.rb +19 -4
  29. data/lib/parse/model/core/fetching.rb +29 -2
  30. data/lib/parse/model/core/field_guards.rb +16 -8
  31. data/lib/parse/model/object.rb +52 -1
  32. data/lib/parse/model/push.rb +11 -1
  33. data/lib/parse/mongodb.rb +19 -1
  34. data/lib/parse/query/constraint.rb +1 -1
  35. data/lib/parse/query/constraints.rb +31 -27
  36. data/lib/parse/query.rb +793 -55
  37. data/lib/parse/stack/version.rb +1 -1
  38. data/lib/parse/stack.rb +19 -0
  39. data/lib/parse/vector_search/hybrid.rb +74 -15
  40. data/lib/parse/vector_search.rb +214 -7
  41. data/lib/parse/webhooks/payload.rb +43 -0
  42. data/lib/parse/webhooks.rb +312 -9
  43. metadata +1 -1
@@ -116,6 +116,49 @@ module Parse
116
116
  end
117
117
  end
118
118
 
119
+ # Process-local record of session objectId to owning user id, kept apart
120
+ # from the identity plane so no session token can ever read a record.
121
+ # Entries expire after `ttl` and the map holds at most {MAX_ENTRIES},
122
+ # dropping the oldest first. Values are stored typed
123
+ # (`{"session_owner" => user_id}`) so a shared store passed in its place
124
+ # cannot confuse a record with an identity entry either.
125
+ class SessionOwnerMap
126
+ MAX_ENTRIES = 10_000
127
+
128
+ def initialize
129
+ @data = {}
130
+ @mutex = Mutex.new
131
+ end
132
+
133
+ def get(key)
134
+ @mutex.synchronize do
135
+ entry = @data[key]
136
+ return nil if entry.nil?
137
+ if entry[:expires_at] < Time.now
138
+ @data.delete(key)
139
+ return nil
140
+ end
141
+ entry[:value]
142
+ end
143
+ end
144
+
145
+ def set(key, value, ttl:)
146
+ @mutex.synchronize do
147
+ @data.delete(key)
148
+ @data[key] = { value: value, expires_at: Time.now + ttl }
149
+ @data.shift while @data.size > MAX_ENTRIES
150
+ end
151
+ end
152
+
153
+ def invalidate(key)
154
+ @mutex.synchronize { @data.delete(key) }
155
+ end
156
+
157
+ def clear
158
+ @mutex.synchronize { @data.clear }
159
+ end
160
+ end
161
+
119
162
  # The outcome of resolving a caller. `user_id` is the `_User.objectId`
120
163
  # owning the session, or `nil` for an anonymous caller. `role_names` is a
121
164
  # `Set` of bare role names (no `role:` prefix) the user inherits
@@ -189,8 +232,26 @@ module Parse
189
232
  @role_cache_ttl = DEFAULT_ROLE_TTL
190
233
  @upstream_role_reader = nil
191
234
  @compare_upstream_roles = false
235
+ # Bumped by every invalidation. A token resolution captures it before
236
+ # calling `/users/me` and does not cache its answer when it moved, so
237
+ # a resolve already in flight when a session is revoked cannot put
238
+ # the revoked token back into the plane. A generation-capable plane
239
+ # (the shared Redis identity plane) keeps a plane-wide counter too,
240
+ # so the same holds across processes. See {#lookup_user_id}.
241
+ @invalidation_epoch = 0
242
+ @epoch_mutex = Mutex.new
243
+ @session_owner_cache = SessionOwnerMap.new
192
244
  end
193
245
 
246
+ # Where {#remember_session_owner} records which user owns a session
247
+ # objectId. A separate store from the identity plane, so a session
248
+ # token can never read a record. Defaults to a process-local
249
+ # {SessionOwnerMap}; set a shared store (anything with `get`, `set`
250
+ # with `ttl:`, `invalidate`, and `clear`) to share records across
251
+ # processes.
252
+ # @return [Object]
253
+ attr_accessor :session_owner_cache
254
+
194
255
  # Apply settings, leaving anything not passed unchanged.
195
256
  # @return [self]
196
257
  def configure(identity_cache: nil, role_cache: nil,
@@ -246,6 +307,7 @@ module Parse
246
307
  # @param session_token [String]
247
308
  def invalidate(session_token)
248
309
  return if session_token.nil?
310
+ bump_invalidation_epoch!
249
311
  @identity_cache.invalidate(session_token.to_s)
250
312
  end
251
313
 
@@ -268,6 +330,7 @@ module Parse
268
330
  def invalidate_user(user_id)
269
331
  return if user_id.nil? || user_id.to_s.empty?
270
332
  uid = user_id.to_s
333
+ bump_invalidation_epoch!
271
334
  cache = @identity_cache
272
335
  if generation_capable?(cache) && cache.respond_to?(:bump_generation)
273
336
  cache.bump_generation(uid)
@@ -294,8 +357,42 @@ module Parse
294
357
 
295
358
  # Drop every entry in both planes.
296
359
  def reset_caches!
360
+ bump_invalidation_epoch!
297
361
  @identity_cache.clear if @identity_cache.respond_to?(:clear)
298
362
  @role_cache.clear if @role_cache.respond_to?(:clear)
363
+ # A plane clear takes the plane-wide marker with it. Bump it again so
364
+ # it does not sit at a value a lookup that started before the reset
365
+ # could have captured.
366
+ bump_plane_epoch!
367
+ end
368
+
369
+ # Record which user owns a session objectId, so a later delete of that
370
+ # session can drop the owner's cached identities even when the delete
371
+ # can no longer read the session row (it is already gone, or not
372
+ # visible to the caller). Only the owner's user id is stored, never the
373
+ # token. Called when a `_Session` row with both is loaded from the
374
+ # server. The entry lives as long as an identity entry.
375
+ # @param session_id [String]
376
+ # @param user_id [String]
377
+ # @return [void]
378
+ def remember_session_owner(session_id, user_id)
379
+ return if session_id.to_s.empty? || user_id.to_s.empty?
380
+ @session_owner_cache.set(session_id.to_s, { "session_owner" => user_id.to_s }, ttl: @identity_cache_ttl)
381
+ nil
382
+ rescue StandardError
383
+ nil
384
+ end
385
+
386
+ # The owner recorded by {#remember_session_owner}, or nil.
387
+ # @param session_id [String]
388
+ # @return [String, nil]
389
+ def session_owner(session_id)
390
+ return nil if session_id.to_s.empty?
391
+ value = @session_owner_cache.get(session_id.to_s)
392
+ owner = value.is_a?(Hash) ? value["session_owner"] : nil
393
+ owner.is_a?(String) && !owner.empty? ? owner : nil
394
+ rescue StandardError
395
+ nil
299
396
  end
300
397
 
301
398
  def inspect
@@ -314,6 +411,14 @@ module Parse
314
411
  cached = cached_user_id(session_token)
315
412
  return cached unless cached.nil?
316
413
 
414
+ # Captured before `/users/me`: an invalidation that lands while the
415
+ # lookup is in flight must win over the answer it returns. The plane
416
+ # marker is created first if a clear removed it, so the snapshot
417
+ # never holds "no marker", which a concurrent reset also produces.
418
+ ensure_plane_marker!
419
+ snapshot = invalidation_snapshot
420
+ prior_uid, prior_gen = prior_generation(session_token)
421
+
317
422
  response = begin
318
423
  # cache: false: a revoked or expired token must not re-resolve
319
424
  # from a cached /users/me response after its identity entry is
@@ -331,10 +436,136 @@ module Parse
331
436
  raise InvalidSession, "session token resolved no user objectId" if user_id.nil? || user_id.to_s.empty?
332
437
 
333
438
  user_id = user_id.to_s
334
- store_user_id(session_token, user_id)
439
+ # A stale entry for the same user tells us its generation from
440
+ # before the lookup; storing under it lets a bump made across
441
+ # processes during the lookup still reject the entry.
442
+ store_unless_invalidated(session_token, user_id, snapshot,
443
+ gen: prior_uid == user_id ? prior_gen : nil)
335
444
  user_id
336
445
  end
337
446
 
447
+ # Cache a resolved identity only if no invalidation happened since
448
+ # `snapshot` was taken, without a window between the check and the
449
+ # write.
450
+ #
451
+ # Every invalidation bumps the counters BEFORE it drops entries. The
452
+ # write is checked before and re-checked after; a change seen by the
453
+ # second check evicts what was just written. So an invalidation either
454
+ # bumped before the re-check (the entry is evicted here) or bumped
455
+ # after it, which means it also drops entries after the write (the
456
+ # entry is removed, or its generation goes stale, by the invalidation
457
+ # itself). Either way the revoked identity does not stay cached. The
458
+ # plane-wide counter extends this to invalidations made by other
459
+ # processes sharing a generation-capable plane.
460
+ def store_unless_invalidated(session_token, user_id, snapshot, gen: nil)
461
+ return unless snapshot_usable?(snapshot)
462
+ return unless invalidation_snapshot == snapshot
463
+ store_user_id(session_token, user_id, gen: gen)
464
+ return if invalidation_snapshot == snapshot
465
+ @identity_cache.invalidate(session_token)
466
+ rescue StandardError
467
+ # Not caching is always safe: the next read resolves again.
468
+ begin
469
+ @identity_cache.invalidate(session_token)
470
+ rescue StandardError
471
+ nil
472
+ end
473
+ end
474
+
475
+ # The process counter plus, on a generation-capable plane, the
476
+ # plane-wide counter shared by every process using that plane.
477
+ def invalidation_snapshot
478
+ [current_invalidation_epoch, plane_epoch]
479
+ end
480
+
481
+ # A snapshot whose plane counter could not be read cannot prove that no
482
+ # other process invalidated during the lookup, so it never caches.
483
+ #
484
+ # An absent marker on a plane that keeps one is not usable either: a
485
+ # reset in another process deletes the marker before it installs a new
486
+ # one, so "absent" before and after the write cannot prove that no reset
487
+ # ran in between. The re-check after the write compares against a
488
+ # present marker, so reading it absent there evicts the write.
489
+ def snapshot_usable?(snapshot)
490
+ snapshot[1] != :unavailable && snapshot[1] != :absent
491
+ end
492
+
493
+ # Create the plane-wide marker when the plane keeps one and it is
494
+ # missing (a fresh plane, or one a reset has just cleared).
495
+ def ensure_plane_marker!
496
+ cache = @identity_cache
497
+ if cache.respond_to?(:invalidation_nonce)
498
+ cache.bump_invalidation_nonce if cache.invalidation_nonce.nil?
499
+ elsif generation_capable?(cache) && cache.respond_to?(:bump_generation)
500
+ gen = cache.generation(PLANE_EPOCH_SUBJECT)
501
+ cache.bump_generation(PLANE_EPOCH_SUBJECT) if gen.nil? || gen.to_i.zero?
502
+ end
503
+ rescue StandardError
504
+ nil
505
+ end
506
+
507
+ def bump_invalidation_epoch!
508
+ @epoch_mutex.synchronize { @invalidation_epoch += 1 }
509
+ bump_plane_epoch!
510
+ end
511
+
512
+ def current_invalidation_epoch
513
+ @epoch_mutex.synchronize { @invalidation_epoch }
514
+ end
515
+
516
+ # Reserved generation subject for the plane-wide invalidation counter
517
+ # on a custom generation-capable plane without an invalidation nonce.
518
+ # Parse objectIds are alphanumeric, so it cannot name a real user.
519
+ PLANE_EPOCH_SUBJECT = "~identity-invalidations"
520
+
521
+ # The plane-wide invalidation marker: a random nonce replaced on every
522
+ # invalidation when the plane offers one ({Parse::Cache::SubCache}
523
+ # does), else a generation counter. nil when the plane has neither,
524
+ # `:absent` when the plane keeps one but it is missing (fresh or just
525
+ # cleared), `:unavailable` when reading it failed. A nonce never repeats,
526
+ # so a plane clear that drops it cannot make an old value come back. A
527
+ # generation counter can: a custom plane whose `clear` also deletes its
528
+ # generations restarts the counter, so a lookup racing another
529
+ # process's reset is bounded by `identity_cache_ttl`. Such a plane
530
+ # should implement `invalidation_nonce` / `bump_invalidation_nonce`.
531
+ def plane_epoch
532
+ cache = @identity_cache
533
+ if cache.respond_to?(:invalidation_nonce)
534
+ nonce = cache.invalidation_nonce
535
+ return nonce.nil? ? :absent : nonce
536
+ end
537
+ return nil unless generation_capable?(cache)
538
+ gen = cache.generation(PLANE_EPOCH_SUBJECT)
539
+ gen.nil? || gen.to_i.zero? ? :absent : gen
540
+ rescue StandardError
541
+ :unavailable
542
+ end
543
+
544
+ def bump_plane_epoch!
545
+ cache = @identity_cache
546
+ if cache.respond_to?(:bump_invalidation_nonce)
547
+ cache.bump_invalidation_nonce
548
+ elsif generation_capable?(cache) && cache.respond_to?(:bump_generation)
549
+ cache.bump_generation(PLANE_EPOCH_SUBJECT)
550
+ end
551
+ rescue StandardError
552
+ nil
553
+ end
554
+
555
+ # The user id and that user's current generation from a stale entry
556
+ # already stored for the token, read before `/users/me`.
557
+ # @return [Array(String, Object), Array(nil, nil)]
558
+ def prior_generation(session_token)
559
+ cache = @identity_cache
560
+ return [nil, nil] unless generation_capable?(cache)
561
+ raw = cache.get(session_token)
562
+ uid = raw.is_a?(Hash) ? (raw["user_id"] || raw[:user_id]) : nil
563
+ return [nil, nil] if uid.nil?
564
+ [uid.to_s, cache.generation(uid.to_s)]
565
+ rescue StandardError
566
+ [nil, nil]
567
+ end
568
+
338
569
  # Read the identity plane and, where the plane supports it, check that
339
570
  # the entry's generation is still current.
340
571
  #
@@ -369,10 +600,11 @@ module Parse
369
600
 
370
601
  # Write the identity entry, tagging it with the subject's current
371
602
  # generation when the plane can track one.
372
- def store_user_id(session_token, user_id)
603
+ def store_user_id(session_token, user_id, gen: nil)
373
604
  cache = @identity_cache
374
605
  if generation_capable?(cache)
375
- cache.set(session_token, { "user_id" => user_id, "gen" => cache.generation(user_id) },
606
+ gen = cache.generation(user_id) if gen.nil?
607
+ cache.set(session_token, { "user_id" => user_id, "gen" => gen },
376
608
  ttl: @identity_cache_ttl)
377
609
  else
378
610
  cache.set(session_token, user_id, ttl: @identity_cache_ttl)
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "digest"
5
+ require "securerandom"
5
6
  require "set"
6
7
 
7
8
  module Parse
@@ -163,6 +164,25 @@ module Parse
163
164
  value
164
165
  end
165
166
 
167
+ # A random marker replaced on every identity invalidation, read by
168
+ # `Parse::Authorization` before and after it caches a token so an
169
+ # invalidation from any process in between is detected. A nonce rather
170
+ # than a counter: a plane clear drops it, and a counter restarting at
171
+ # 0 could repeat a value an in-flight lookup captured.
172
+ # @return [String, nil] nil when never set or cleared.
173
+ def invalidation_nonce
174
+ value = @store[invalidation_nonce_key]
175
+ value.nil? ? nil : value.to_s
176
+ end
177
+
178
+ # Replace the invalidation nonce.
179
+ # @return [String] the new nonce.
180
+ def bump_invalidation_nonce
181
+ nonce = SecureRandom.hex(16)
182
+ @store.store(invalidation_nonce_key, nonce, {})
183
+ nonce
184
+ end
185
+
166
186
  # @return [Float] the last invalidation epoch, 0.0 when never set.
167
187
  def epoch
168
188
  @store[epoch_key].to_f
@@ -253,6 +273,12 @@ module Parse
253
273
  @keyspace.key(@family, "meta", "epoch")
254
274
  end
255
275
 
276
+ # Sits beside the epoch under the reserved `meta` segment; a token key
277
+ # is a digest, so no token can name it.
278
+ def invalidation_nonce_key
279
+ @keyspace.key(@family, "meta", "invalidation")
280
+ end
281
+
256
282
  # Generations live in the same family so a plane clear takes them with it,
257
283
  # and are namespaced under `gen:` so they cannot collide with an entry
258
284
  # whose logical key happens to be a user id.
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "faraday"
5
+ require "securerandom"
5
6
  require "active_support"
6
7
  require "active_support/core_ext"
7
8
 
@@ -15,6 +16,18 @@ module Parse
15
16
  include Parse::Protocol
16
17
  # @!visibility private
17
18
  DISABLE_MASTER_KEY = "X-Disable-Parse-Master-Key".freeze
19
+ # SDK-internal marker for a request that reads metadata (a class
20
+ # schema, the role graph) while enforcing a scope. Such a request keeps
21
+ # the master key inside {Parse.without_master_key}, which governs row
22
+ # access. The header carries a random per-process token and is removed
23
+ # before the request is logged or sent, so a header copied from user
24
+ # input cannot set it. {Parse::Client#request} adds it only for the
25
+ # `metadata_master:` request option set to
26
+ # {Parse::Client::METADATA_MASTER_REQUEST}.
27
+ # @!visibility private
28
+ METADATA_MASTER = "X-Parse-Stack-Metadata-Master".freeze
29
+ # @!visibility private
30
+ METADATA_MASTER_TOKEN = SecureRandom.hex(32).freeze
18
31
  # @return [String] the application id for this Parse endpoint.
19
32
  attr_accessor :application_id
20
33
  # @return [String] the REST API Key for this Parse endpoint.
@@ -67,7 +80,12 @@ module Parse
67
80
  # 3. A session-token-authenticated request (the existing check
68
81
  # below; session token wins over master key).
69
82
  header_disable = env[:request_headers][DISABLE_MASTER_KEY].present?
70
- fiber_disable = Parse.master_key_disabled?
83
+ # An SDK metadata read keeps the master key inside the block. Always
84
+ # remove the marker so it never reaches the logger or the server.
85
+ metadata_value = env[:request_headers].delete(METADATA_MASTER)
86
+ metadata_master = metadata_value.is_a?(String) &&
87
+ ActiveSupport::SecurityUtils.secure_compare(metadata_value, METADATA_MASTER_TOKEN)
88
+ fiber_disable = Parse.master_key_disabled? && !metadata_master
71
89
  unless @master_key.blank? || header_disable || fiber_disable
72
90
  headers[MASTER_KEY] = @master_key
73
91
  end
@@ -266,7 +266,10 @@ class Array
266
266
  # Each object whose delete succeeds has its local state updated the same
267
267
  # way {Parse::Object#destroy} updates it. Objects whose delete fails are
268
268
  # left untouched; inspect the returned batch's responses to find them.
269
- # Destroy callbacks are not run.
269
+ # Destroy callbacks are not run. Every {Parse::Session} and {Parse::User}
270
+ # whose delete succeeded, or reported "object not found" (the row is
271
+ # already gone, for example revoked elsewhere), is dropped from its
272
+ # client's identity plane, as their single-object destroy does.
270
273
  # @example
271
274
  # # assume Post and Author are Parse models
272
275
  # author = Author.first
@@ -281,6 +284,19 @@ class Array
281
284
  raise ArgumentError, "Array#destroy requires Parse::Object elements; " \
282
285
  "this array holds none (#{first.class})"
283
286
  end
287
+ # A session deleted from a pointer or a partial fetch carries no token or
288
+ # owner to drop from the identity cache; look them up first, in one
289
+ # query per client.
290
+ Parse::Session.send(:_preload_identity_for_destroy!, targets) if defined?(Parse::Session)
291
+ _destroy_batch(targets)
292
+ ensure
293
+ # A looked-up session token is a live credential; never leave it on an
294
+ # object whose delete was skipped or whose batch raised.
295
+ targets&.each { |o| o.send(:_clear_identity_for_destroy!) if _batch_identity_hook?(o, :_clear_identity_for_destroy!) }
296
+ end
297
+
298
+ # @!visibility private
299
+ def _destroy_batch(targets)
284
300
  batch = Parse::BatchOperation.new
285
301
  objects = {}
286
302
  targets.each do |o|
@@ -292,7 +308,13 @@ class Array
292
308
  end
293
309
  batch.submit do |request, response|
294
310
  o = objects[request.tag]
295
- next unless o && response.respond_to?(:success?) && response.success?
311
+ next unless o
312
+ # Sessions and users drop their identity-plane entries when the delete
313
+ # applied or the row is already gone ("object not found"), so a revoked
314
+ # token stops resolving now. They decide from the response; a denied
315
+ # delete leaves the cache alone.
316
+ o.send(:_after_batch_destroy, response) if _batch_identity_hook?(o, :_after_batch_destroy)
317
+ next unless response.respond_to?(:success?) && response.success?
296
318
  # Mirror Parse::Object#destroy: keep the id and mark the object
297
319
  # destroyed so it reports `destroyed?` and a later save refuses.
298
320
  o.instance_variable_set(:@_destroyed, true)
@@ -300,6 +322,17 @@ class Array
300
322
  end
301
323
  batch
302
324
  end
325
+ private :_destroy_batch
326
+
327
+ # Whether `o`'s class defines the internal (non-public) identity hook
328
+ # `name`. Checked on the class rather than with `respond_to?(name, true)`
329
+ # so a duck-typed element overriding `respond_to?` cannot break the batch.
330
+ # @!visibility private
331
+ def _batch_identity_hook?(o, name)
332
+ klass = o.class
333
+ klass.private_method_defined?(name) || klass.method_defined?(name)
334
+ end
335
+ private :_batch_identity_hook?
303
336
 
304
337
  # Do not alias method as :delete is already part of array.
305
338
  # alias_method :delete, :destroy
data/lib/parse/client.rb CHANGED
@@ -283,6 +283,14 @@ module Parse
283
283
  DEFAULT_RETRIES = 2
284
284
  # The wait time in seconds between retries
285
285
  RETRY_DELAY = 1.5
286
+ # SDK-internal value for the `metadata_master:` request option. A request
287
+ # that passes it together with `use_master_key: true` reads metadata (a
288
+ # class schema, the role graph) and keeps the master key inside
289
+ # {Parse.without_master_key}. Only this exact object turns it on.
290
+ # @!visibility private
291
+ METADATA_MASTER_REQUEST = Object.new.tap do |o|
292
+ def o.inspect = "#<Parse::Client::METADATA_MASTER_REQUEST>"
293
+ end.freeze
286
294
 
287
295
  # An error when a general response error occurs when communicating with Parse server.
288
296
  class ResponseError < Parse::Error; end
@@ -1319,7 +1327,10 @@ module Parse
1319
1327
  body ||= _request.body
1320
1328
  headers.merge! _request.headers
1321
1329
  else
1322
- _request = Parse::Request.new(method, uri, body: body, headers: headers, opts: opts)
1330
+ # The metadata-master sentinel stays off the stored request: a
1331
+ # caller replaying `response.request` later must not inherit it.
1332
+ _request = Parse::Request.new(method, uri, body: body, headers: headers,
1333
+ opts: opts.except(:metadata_master))
1323
1334
  # Request copies the headers it is given, so carry its request id
1324
1335
  # back onto the outgoing headers.
1325
1336
  headers.merge!(_request.headers)
@@ -1348,6 +1359,14 @@ module Parse
1348
1359
  # never sent unless the caller explicitly passed `use_master_key: true`
1349
1360
  explicit_master = opts.key?(:use_master_key)
1350
1361
 
1362
+ # SDK metadata reads (class schemas, the role graph) keep the master
1363
+ # key inside `Parse.without_master_key`. Only the internal sentinel
1364
+ # turns this on; a `true` or any other value from a caller does not.
1365
+ # The marker goes on a per-attempt copy of the headers handed to the
1366
+ # connection (below), never on `headers` or the stored request, so a
1367
+ # retry, `response.request`, or an error reporter never carries it.
1368
+ metadata_master = opts[:metadata_master].equal?(METADATA_MASTER_REQUEST) && opts[:use_master_key] == true
1369
+
1351
1370
  if opts[:use_master_key] == false
1352
1371
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1353
1372
  elsif Parse.client_mode && opts[:use_master_key] != true
@@ -1417,7 +1436,12 @@ module Parse
1417
1436
  params = (method == :get ? query : body) || {}
1418
1437
  # if the path does not start with the '/1/' prefix, then add it to be nice.
1419
1438
  # actually send the request and return the body
1420
- response_env = @conn.send(method, uri, params, headers)
1439
+ send_headers = headers
1440
+ if metadata_master
1441
+ send_headers = headers.merge(Parse::Middleware::Authentication::METADATA_MASTER =>
1442
+ Parse::Middleware::Authentication::METADATA_MASTER_TOKEN)
1443
+ end
1444
+ response_env = @conn.send(method, uri, params, send_headers)
1421
1445
  response = response_env.body
1422
1446
  response.request = _request
1423
1447
 
@@ -86,7 +86,14 @@ module Parse
86
86
  # at request rate.
87
87
  NEGATIVE_TTL = 5
88
88
 
89
+ # How long a schema fetch that failed inside `Parse.without_master_key`
90
+ # is remembered. Kept apart from the shared cache and consulted only
91
+ # inside the block, so it never denies another caller's reads; it only
92
+ # stops a down server from being refetched on every read in the block.
93
+ SUPPRESSED_NEGATIVE_TTL = 2
94
+
89
95
  @cache = {}
96
+ @suppressed_failures = {}
90
97
  @cache_mutex = Mutex.new
91
98
  @cache_ttl = POSITIVE_TTL
92
99
 
@@ -490,7 +497,10 @@ module Parse
490
497
  end
491
498
 
492
499
  def reset_cache!
493
- @cache_mutex.synchronize { @cache.clear }
500
+ @cache_mutex.synchronize do
501
+ @cache.clear
502
+ @suppressed_failures.clear
503
+ end
494
504
  # Also drop the unresolvable-class warned-once registry so
495
505
  # tests that assert on `warn` emission for a class don't get
496
506
  # silenced by an earlier test's call.
@@ -599,6 +609,12 @@ module Parse
599
609
  cached = @cache_mutex.synchronize { @cache[key] }
600
610
  return cached if cached && !stale?(cached)
601
611
 
612
+ suppressed = Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
613
+ if suppressed
614
+ failed_at = @cache_mutex.synchronize { @suppressed_failures[key] }
615
+ return unresolvable_entry if failed_at && monotonic_now - failed_at < SUPPRESSED_NEGATIVE_TTL
616
+ end
617
+
602
618
  entry = if resolved_client.nil?
603
619
  # No client configured (Parse.setup never called, etc.) —
604
620
  # treat as unresolvable so we fail closed instead of
@@ -620,7 +636,17 @@ module Parse
620
636
  end
621
637
  end
622
638
 
623
- @cache_mutex.synchronize { @cache[key] = entry }
639
+ # A failure inside `Parse.without_master_key` is not put in the
640
+ # shared cache. The request keeps the master key there, but a custom
641
+ # schema source may not, and a negative entry is shared by every
642
+ # caller of this class for {NEGATIVE_TTL}: one block must not deny
643
+ # every other request's scoped reads. It is remembered briefly in a
644
+ # separate table read only inside the block.
645
+ if entry.kind == :unresolvable && suppressed
646
+ @cache_mutex.synchronize { @suppressed_failures[key] = monotonic_now }
647
+ else
648
+ @cache_mutex.synchronize { @cache[key] = entry }
649
+ end
624
650
  entry
625
651
  end
626
652
 
@@ -637,6 +663,13 @@ module Parse
637
663
  # and the client-bound session token. A client that holds no master key
638
664
  # still cannot read schemas, which stays fail-closed.
639
665
  #
666
+ # The schema is metadata, so the request also carries the SDK's
667
+ # metadata marker: it keeps the master key inside
668
+ # `Parse.without_master_key`, which strips it from every other request
669
+ # in the block. Without it the fetch was refused there and the
670
+ # `:unresolvable` entry denied every scoped read of the class, for all
671
+ # callers, until it expired.
672
+ #
640
673
  # Objects that only implement `#schema` (test doubles, custom schema
641
674
  # sources installed via {.schema_client}), and clients whose `#schema`
642
675
  # was overridden on the instance, keep the old call.
@@ -649,7 +682,8 @@ module Parse
649
682
  if resolved_client.is_a?(Parse::Client) && stock_schema
650
683
  safe = Parse::API::PathSegment.identifier!(class_key, kind: "class name")
651
684
  resolved_client.request(:get, "schemas/#{safe}",
652
- opts: { cache: false, use_master_key: true })
685
+ opts: { cache: false, use_master_key: true,
686
+ metadata_master: Parse::Client::METADATA_MASTER_REQUEST })
653
687
  else
654
688
  resolved_client.schema(class_key)
655
689
  end