parse-stack-next 5.8.0 → 5.8.2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
@@ -41,6 +41,65 @@ module Parse
41
41
  # Used to correlate batching requests with their responses.
42
42
  attr_accessor :tag
43
43
 
44
+ # @!visibility private
45
+ # The client this request belongs to, when it was built for a specific
46
+ # one (an object's class client). A {Parse::BatchOperation} sends a
47
+ # request through this client, so a write built for a session-bound
48
+ # client is never batched through another client's credentials. nil
49
+ # means the batch's own client.
50
+ attr_accessor :client
51
+
52
+ # @!visibility private
53
+ # The credentials this request names explicitly, resolved the way
54
+ # {Parse::Client#request} resolves them for a single request: the
55
+ # `session_token:` option, else an `X-Parse-Session-Token` header; and
56
+ # the `use_master_key:` option; and `suppress_master_key: true` when the
57
+ # master-key suppression header is set. A key is absent when the request
58
+ # does not name it, so ambient context (`Parse.with_session`,
59
+ # `client_mode`, a bound client token) still applies.
60
+ #
61
+ # A `session_token:` option that is present but resolves to no token (a
62
+ # user object without one, or a blank string) is returned as `""`, so a
63
+ # batch fails closed the same way a single request does: no master key
64
+ # and no ambient or bound token. `use_master_key:` is returned only when
65
+ # it is exactly `true` or `false`; {Parse::Client#request} treats any
66
+ # other value as not set, and so does a batch.
67
+ # @return [Hash] with optional `:session_token` (String),
68
+ # `:use_master_key` (Boolean), and `:suppress_master_key` (true) keys.
69
+ def explicit_authority
70
+ o = opts.is_a?(Hash) ? opts : {}
71
+ result = {}
72
+ token = Parse::BatchOperation.resolve_session_option(o, :session_token)
73
+ if token.nil?
74
+ header_token = self.class.header_value(headers, Parse::Protocol::SESSION_TOKEN)
75
+ if header_token.is_a?(String)
76
+ token = header_token.strip.empty? ? "" : header_token
77
+ end
78
+ end
79
+ result[:session_token] = token unless token.nil?
80
+ master = o[:use_master_key]
81
+ result[:use_master_key] = master if master == true || master == false
82
+ # The suppression header wins over `use_master_key: true` in the
83
+ # authentication middleware, so a batch must carry it as a header
84
+ # rather than fold it into `use_master_key:` (which would also change
85
+ # how the ambient and bound session tokens apply).
86
+ if self.class.header_value(headers, Parse::Middleware::Authentication::DISABLE_MASTER_KEY).present?
87
+ result[:suppress_master_key] = true
88
+ end
89
+ result
90
+ end
91
+
92
+ # @!visibility private
93
+ # Case-insensitive header lookup.
94
+ # @return [Object, nil]
95
+ def self.header_value(headers, name)
96
+ return nil unless headers.is_a?(Hash)
97
+ return headers[name] if headers.key?(name)
98
+ wanted = name.to_s.downcase
99
+ headers.each { |k, v| return v if k.to_s.downcase == wanted }
100
+ nil
101
+ end
102
+
44
103
  # @!attribute [rw] request_id
45
104
  # @return [String] unique identifier for this request to enable idempotency
46
105
  attr_accessor :request_id
@@ -264,19 +264,77 @@ module Parse
264
264
  self
265
265
  end
266
266
 
267
+ # A summary that never prints result values: login, signup, and
268
+ # `users/me` responses carry a live `sessionToken` (and sometimes
269
+ # `authData`), and any other row can carry private data, so inspect
270
+ # shows only the shape of the result.
267
271
  # @!visibility private
268
272
  def inspect
269
273
  if error?
270
- "#<#{self.class} @code=#{code} @error='#{error}'>"
274
+ "#<#{self.class} @code=#{code} @error='#{safe_error_text}' @http_status=#{http_status.inspect}>"
271
275
  else
272
- "#<#{self.class} @result='#{@result}'>"
276
+ "#<#{self.class} @http_status=#{http_status.inspect} @result=#{result_summary}>"
273
277
  end
274
278
  end
275
279
 
276
- # @return [String] JSON encoded object, or an error string.
280
+ # @return [String] JSON encoded object, or an error string. Credential
281
+ # fields (`sessionToken`, `password`, `authData`, MFA recovery codes
282
+ # and secrets, keys) are replaced with a placeholder, and credential
283
+ # text inside string values (`password=x`, `X-Parse-Master-Key: mk`)
284
+ # is filtered too, so printing the response with `to_s` or `inspect`
285
+ # does not leak a live session token. The error form has its text redacted and control characters
286
+ # escaped. `#result`, `#to_json`, and `#as_json` return the raw values
287
+ # by design; do not log those.
277
288
  def to_s
278
- return "[E-#{@code}] #{@request} : #{@error} (#{@http_status})" if error?
279
- @result.to_json
289
+ if error?
290
+ return "[E-#{@code}] #{safe_request_text} : #{safe_error_text} (#{@http_status})"
291
+ end
292
+ result = redacted_result
293
+ return Parse::Middleware::BodyBuilder.redact_patterns(result) if result.is_a?(String)
294
+ # Text patterns run on string values before encoding, never on the
295
+ # encoded JSON, so escaped quotes cannot defeat them or corrupt it.
296
+ Parse::Middleware::BodyBuilder.redact_string_values!(result).to_json
297
+ end
298
+
299
+ private
300
+
301
+ # Maximum error text length kept in `to_s` / `inspect`.
302
+ SAFE_ERROR_TEXT_LENGTH = 1_000
303
+
304
+ # The server's error text with credentials redacted, truncated, and
305
+ # control characters escaped, matching what the client logs.
306
+ def safe_error_text
307
+ text = Parse::Middleware::BodyBuilder.redact(@error.to_s)[0, SAFE_ERROR_TEXT_LENGTH]
308
+ Parse::TerminalSafe.sanitize_line(text)
309
+ rescue StandardError
310
+ "[unprintable error]"
311
+ end
312
+
313
+ # The request line (method and path, including any query string) with
314
+ # credentials redacted and control characters escaped.
315
+ def safe_request_text
316
+ Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(@request.to_s))
317
+ rescue StandardError
318
+ "[unprintable request]"
319
+ end
320
+
321
+ # Class and size of the result, without its values.
322
+ def result_summary
323
+ case @result
324
+ when Array then "Array(#{@result.size})"
325
+ when Hash then "Hash(#{@result.size} keys)"
326
+ when nil then "nil"
327
+ else @result.class.name
328
+ end
329
+ end
330
+
331
+ # A deep copy of the result with credential fields replaced.
332
+ def redacted_result
333
+ return @result unless @result.is_a?(Hash) || @result.is_a?(Array)
334
+ copy = JSON.parse(@result.to_json)
335
+ Parse::Middleware::BodyBuilder.scrub_sensitive!(copy)
336
+ rescue StandardError
337
+ "[unprintable result]"
280
338
  end
281
339
  end
282
340
  end
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
@@ -411,6 +419,39 @@ module Parse
411
419
  "session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
412
420
  end
413
421
 
422
+ # Redacted JSON form. ActiveSupport's default `as_json` serializes every
423
+ # instance variable, so a client in a JSON log or error-tracker context
424
+ # (or inside an object that serializes it, such as a {Parse::Agent})
425
+ # would emit the master key, REST key, and bound session token.
426
+ # @return [Hash]
427
+ def as_json(*)
428
+ {
429
+ "server_url" => @server_url,
430
+ "app_id" => @application_id,
431
+ "master_key" => @master_key ? "[FILTERED]" : nil,
432
+ "session_token" => @session_token ? "[FILTERED]" : nil,
433
+ }
434
+ end
435
+
436
+ # @return [String] the redacted {#as_json} summary as JSON.
437
+ def to_json(*args)
438
+ as_json.to_json(*args)
439
+ end
440
+
441
+ # YAML (Psych) serializes instance variables too; emit the redacted
442
+ # summary instead.
443
+ def encode_with(coder)
444
+ as_json.each { |k, v| coder[k] = v }
445
+ end
446
+
447
+ # A client holds the master key, REST key, and session tokens, so it is
448
+ # never marshaled (Rails.cache, DRb, or a job payload would store them in
449
+ # the clear). Store the configuration and build a new client instead.
450
+ # @raise [TypeError]
451
+ def marshal_dump
452
+ raise TypeError, "Parse::Client cannot be marshaled"
453
+ end
454
+
414
455
  # A NEW non-master {Parse::Client} that mirrors THIS client's connection
415
456
  # settings (`server_url` / `application_id` / `api_key`) but carries no
416
457
  # master key and binds `session_token`, so it acts on the server as that
@@ -637,6 +678,12 @@ module Parse
637
678
  # middleware. The default value is 3 seconds. If :expires is set to 0,
638
679
  # caching will be disabled. You can always clear the current state of the
639
680
  # cache using the clear_cache! method on your Parse::Client instance.
681
+ # @option opts [Boolean] :cache_session_requests Whether this client's
682
+ # response cache stores and serves reads made with a session token.
683
+ # Overrides {Parse::Middleware::Caching.cache_session_requests} in
684
+ # either direction; omit it to use that class default (off). Only
685
+ # `true` enables it. With it on, a revoked session keeps reading its
686
+ # cached rows until the entry expires.
640
687
  # @option opts [String] :cache_namespace Optional prefix applied to every
641
688
  # cache key. Useful when two Parse apps share one Redis instance and
642
689
  # would otherwise collide on identical paths (e.g.
@@ -696,7 +743,9 @@ module Parse
696
743
  @allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false)
697
744
 
698
745
  # Security check for HTTP usage (except localhost/127.0.0.1 for development)
699
- if @server_url&.start_with?("http://") && !@server_url.match?(%r{^http://(localhost|127\.0\.0\.1)(:|/)})
746
+ # The scheme and host come from URI parsing, so `HTTP://` and leading
747
+ # whitespace are treated as plain http, not as a secure URL.
748
+ if self.class.url_scheme(@server_url) == "http" && !self.class.loopback_host?(self.class.url_host(@server_url))
700
749
  if @require_https
701
750
  raise ArgumentError, "[Parse::Client] HTTPS required but server URL uses HTTP: #{@server_url}. " \
702
751
  "Set require_https: false or use an HTTPS URL."
@@ -757,7 +806,7 @@ module Parse
757
806
  # scheme; without this guard a caller passing
758
807
  # faraday: { ssl: { verify: false }, proxy: "http://attacker" }
759
808
  # would neuter TLS verification on an HTTPS connection.
760
- validate_faraday_opts!(opts[:faraday])
809
+ opts[:faraday] = validate_faraday_opts!(opts[:faraday])
761
810
  opts[:faraday].merge!(:url => @server_url)
762
811
  @conn = Faraday.new(opts[:faraday]) do |conn|
763
812
  # Apply timeouts before any user-supplied middleware sees a request.
@@ -929,6 +978,10 @@ module Parse
929
978
  # old workers still read the legacy shape, so invalidation has to
930
979
  # hit both until every old worker is drained.
931
980
  delete_legacy_variants: opts.fetch(:cache_delete_legacy_variants, true),
981
+ # Per-client override of
982
+ # Parse::Middleware::Caching.cache_session_requests; omitted
983
+ # means the class default applies.
984
+ **(opts.key?(:cache_session_requests) ? { cache_session_requests: opts[:cache_session_requests] } : {}),
932
985
  }
933
986
 
934
987
  # Inform about opt-in cache behavior
@@ -978,19 +1031,27 @@ module Parse
978
1031
  # controlled MITM unless explicitly allowlisted
979
1032
  #
980
1033
  # @api private
1034
+ # @return [Hash] the options as a Hash, so a `Faraday::ConnectionOptions`
1035
+ # (or anything else responding to `to_hash`) is checked and used in the
1036
+ # same form; any other value raises.
981
1037
  def validate_faraday_opts!(faraday_opts)
982
- return unless faraday_opts.is_a?(Hash)
983
-
984
- ssl = faraday_opts[:ssl] || faraday_opts["ssl"]
985
- if ssl.is_a?(Hash)
986
- verify = ssl.key?(:verify) ? ssl[:verify] : ssl["verify"]
987
- if verify == false && @server_url.to_s.start_with?("https://")
1038
+ faraday_opts = {} if faraday_opts.nil?
1039
+ unless faraday_opts.is_a?(Hash)
1040
+ unless faraday_opts.respond_to?(:to_hash)
988
1041
  raise ArgumentError,
989
- "[Parse::Client] Refusing to disable TLS certificate verification " \
990
- "(opts[:faraday][:ssl][:verify] = false) on an HTTPS server URL. " \
991
- "Fix the server certificate or downgrade the URL to http:// " \
992
- "(with require_https: false) for explicit local testing."
1042
+ "[Parse::Client] opts[:faraday] must be a Hash or Faraday::ConnectionOptions " \
1043
+ "(got #{faraday_opts.class})."
993
1044
  end
1045
+ faraday_opts = faraday_opts.to_hash
1046
+ end
1047
+
1048
+ ssl = faraday_opts[:ssl] || faraday_opts["ssl"]
1049
+ if self.class.url_scheme(@server_url) == "https" && (setting = tls_verification_disabled(ssl))
1050
+ raise ArgumentError,
1051
+ "[Parse::Client] Refusing to disable TLS certificate verification " \
1052
+ "(opts[:faraday][:ssl] #{setting}) on an HTTPS server URL. " \
1053
+ "Fix the server certificate or downgrade the URL to http:// " \
1054
+ "(with require_https: false) for explicit local testing."
994
1055
  end
995
1056
 
996
1057
  proxy = faraday_opts[:proxy] || faraday_opts["proxy"]
@@ -1010,15 +1071,82 @@ module Parse
1010
1071
  # `proxy: nil` is the Faraday-documented way to disable
1011
1072
  # env-proxy autodiscovery.
1012
1073
  faraday_opts[:proxy] = nil unless @allow_faraday_proxy
1074
+ faraday_opts
1013
1075
  end
1014
1076
 
1015
1077
  private :validate_faraday_opts!
1016
1078
 
1017
- # Hosts considered "loopback" for the cleartext-ws:// guard in
1018
- # {#configure_live_query}. Mirrors
1019
- # {Parse::LiveQuery::Client::LOOPBACK_HOSTS} so the explicit-URL
1020
- # path and the derived-URL path agree on what counts as local.
1021
- LIVE_QUERY_LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1] 0.0.0.0].freeze
1079
+ # Which TLS setting in a Faraday `ssl:` option (a Hash or a
1080
+ # `Faraday::SSLOptions`) turns certificate or hostname verification off,
1081
+ # or nil when none does: `verify: false`, `verify_mode: VERIFY_NONE`, or
1082
+ # `verify_hostname: false`.
1083
+ # @api private
1084
+ def tls_verification_disabled(ssl)
1085
+ read = lambda do |key|
1086
+ if ssl.is_a?(Hash)
1087
+ ssl.key?(key) ? ssl[key] : ssl[key.to_s]
1088
+ elsif ssl.respond_to?(key)
1089
+ ssl.public_send(key)
1090
+ end
1091
+ end
1092
+ return nil if ssl.nil?
1093
+ return "verify: false" if read.call(:verify) == false
1094
+ if defined?(OpenSSL::SSL::VERIFY_NONE) && read.call(:verify_mode) == OpenSSL::SSL::VERIFY_NONE
1095
+ return "verify_mode: VERIFY_NONE"
1096
+ end
1097
+ return "verify_hostname: false" if read.call(:verify_hostname) == false
1098
+ nil
1099
+ end
1100
+ private :tls_verification_disabled
1101
+
1102
+ # Whether a URL host is this machine: `localhost`, any `127.0.0.0/8`
1103
+ # address, `::1`, or `0.0.0.0` (a connect to the unspecified address
1104
+ # reaches the local host on Linux and macOS). Used by the http:// server
1105
+ # warning and the LiveQuery cleartext guards so they agree.
1106
+ # @param host [String, nil]
1107
+ # @return [Boolean]
1108
+ # @api private
1109
+ #
1110
+ # Addresses are parsed with IPAddr, so a malformed one such as
1111
+ # `127.999.1.1` (which a resolver would look up by name) is not loopback.
1112
+ # `localhost.` with a trailing dot is not either: some resolvers skip
1113
+ # `/etc/hosts` for the fully qualified form. `0.0.0.0` stays local for
1114
+ # client URLs only (it is a bind address everywhere else).
1115
+ def self.loopback_host?(host)
1116
+ h = host.to_s.strip.downcase.delete_prefix("[").delete_suffix("]")
1117
+ return false if h.empty?
1118
+ return true if h == "localhost" || h == "0.0.0.0"
1119
+ return false unless h.match?(/\A[0-9a-f:.]+\z/)
1120
+ require "ipaddr"
1121
+ IPAddr.new(h).loopback?
1122
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
1123
+ false
1124
+ end
1125
+
1126
+ # The lowercased scheme of a URL String, or nil when it does not parse.
1127
+ # Leading and trailing whitespace is ignored. Scheme checks go through
1128
+ # this instead of a case-sensitive prefix match, which let `HTTP://` and
1129
+ # `WS://` past the plaintext guards.
1130
+ # @param url [String, nil]
1131
+ # @return [String, nil]
1132
+ # @api private
1133
+ def self.url_scheme(url)
1134
+ return nil if url.nil?
1135
+ URI.parse(url.to_s.strip).scheme&.downcase
1136
+ rescue URI::InvalidURIError
1137
+ nil
1138
+ end
1139
+
1140
+ # The lowercased host of a URL String, or nil when it does not parse.
1141
+ # @param url [String, nil]
1142
+ # @return [String, nil]
1143
+ # @api private
1144
+ def self.url_host(url)
1145
+ return nil if url.nil?
1146
+ URI.parse(url.to_s.strip).host&.downcase
1147
+ rescue URI::InvalidURIError
1148
+ nil
1149
+ end
1022
1150
 
1023
1151
  # Configure LiveQuery with the given options
1024
1152
  # @param opts [Hash] configuration options
@@ -1041,7 +1169,7 @@ module Parse
1041
1169
  # `live_query: { url: "ws://prod-host" }` or
1042
1170
  # `live_query_url: "ws://prod-host"` bypassed it — the master key
1043
1171
  # and any session token would ride the connect frame in cleartext.
1044
- validate_live_query_url!(resolved_url, allow_insecure: live_query_opts[:allow_insecure])
1172
+ resolved_url = validate_live_query_url!(resolved_url, allow_insecure: live_query_opts[:allow_insecure])
1045
1173
 
1046
1174
  # Warn (don't raise) on `live_query: { ... }` keys that are not
1047
1175
  # `Parse::LiveQuery::Configuration` setters. The block form would
@@ -1071,20 +1199,14 @@ module Parse
1071
1199
  end
1072
1200
  end
1073
1201
 
1202
+ # Validate an explicit LiveQuery URL at configure time with the same
1203
+ # rules {Parse::LiveQuery::Client} applies, and return the URL to use
1204
+ # (`http://` and `https://` are mapped to `ws://` and `wss://`).
1205
+ # @return [String, nil]
1074
1206
  # @api private
1075
1207
  def validate_live_query_url!(url, allow_insecure:)
1076
- return unless url.is_a?(String) && url.start_with?("ws://")
1077
-
1078
- host = URI.parse(url).host.to_s rescue ""
1079
- return if LIVE_QUERY_LOOPBACK_HOSTS.include?(host)
1080
- return if allow_insecure
1081
-
1082
- raise ArgumentError,
1083
- "[Parse::Client] Refusing explicit insecure LiveQuery URL #{url.inspect}. " \
1084
- "The connect frame carries the master key and any session token in " \
1085
- "plaintext on this socket. Use wss:// for routable hosts, or pass " \
1086
- "`live_query: { allow_insecure: true }` to opt into cleartext for " \
1087
- "local development on a non-loopback address."
1208
+ return url unless url.is_a?(String)
1209
+ Parse::LiveQuery::Client.normalize_url(url, allow_insecure: allow_insecure)
1088
1210
  end
1089
1211
 
1090
1212
  # @api private
@@ -1319,7 +1441,10 @@ module Parse
1319
1441
  body ||= _request.body
1320
1442
  headers.merge! _request.headers
1321
1443
  else
1322
- _request = Parse::Request.new(method, uri, body: body, headers: headers, opts: opts)
1444
+ # The metadata-master sentinel stays off the stored request: a
1445
+ # caller replaying `response.request` later must not inherit it.
1446
+ _request = Parse::Request.new(method, uri, body: body, headers: headers,
1447
+ opts: opts.except(:metadata_master))
1323
1448
  # Request copies the headers it is given, so carry its request id
1324
1449
  # back onto the outgoing headers.
1325
1450
  headers.merge!(_request.headers)
@@ -1348,6 +1473,14 @@ module Parse
1348
1473
  # never sent unless the caller explicitly passed `use_master_key: true`
1349
1474
  explicit_master = opts.key?(:use_master_key)
1350
1475
 
1476
+ # SDK metadata reads (class schemas, the role graph) keep the master
1477
+ # key inside `Parse.without_master_key`. Only the internal sentinel
1478
+ # turns this on; a `true` or any other value from a caller does not.
1479
+ # The marker goes on a per-attempt copy of the headers handed to the
1480
+ # connection (below), never on `headers` or the stored request, so a
1481
+ # retry, `response.request`, or an error reporter never carries it.
1482
+ metadata_master = opts[:metadata_master].equal?(METADATA_MASTER_REQUEST) && opts[:use_master_key] == true
1483
+
1351
1484
  if opts[:use_master_key] == false
1352
1485
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1353
1486
  elsif Parse.client_mode && opts[:use_master_key] != true
@@ -1364,7 +1497,9 @@ module Parse
1364
1497
  # `with_session(token_b)` resolved user B, and the identity cache
1365
1498
  # then mapped token A (or a garbage token) to user B for its TTL.
1366
1499
  if raw_token.nil?
1367
- header_token = headers[Parse::Protocol::SESSION_TOKEN]
1500
+ # Same lookup a batch uses for each request's own credentials
1501
+ # (Parse::Request#explicit_authority).
1502
+ header_token = Parse::Request.header_value(headers, Parse::Protocol::SESSION_TOKEN)
1368
1503
  raw_token = header_token if header_token.is_a?(String)
1369
1504
  end
1370
1505
  # SEC-02:an EXPLICITLY-supplied session_token that is a blank /
@@ -1417,7 +1552,12 @@ module Parse
1417
1552
  params = (method == :get ? query : body) || {}
1418
1553
  # if the path does not start with the '/1/' prefix, then add it to be nice.
1419
1554
  # actually send the request and return the body
1420
- response_env = @conn.send(method, uri, params, headers)
1555
+ send_headers = headers
1556
+ if metadata_master
1557
+ send_headers = headers.merge(Parse::Middleware::Authentication::METADATA_MASTER =>
1558
+ Parse::Middleware::Authentication::METADATA_MASTER_TOKEN)
1559
+ end
1560
+ response_env = @conn.send(method, uri, params, send_headers)
1421
1561
  response = response_env.body
1422
1562
  response.request = _request
1423
1563
 
@@ -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