parse-stack-next 5.6.0 → 5.7.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +219 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
data/lib/parse/client.rb CHANGED
@@ -32,6 +32,8 @@ require_relative "client/body_builder"
32
32
  require_relative "client/authentication"
33
33
  require_relative "client/caching"
34
34
  require_relative "cache/redis"
35
+ require_relative "cache/invalidation"
36
+ require_relative "authorization"
35
37
  require_relative "client/logging"
36
38
  require_relative "client/profiling"
37
39
  require_relative "api/all"
@@ -219,7 +221,22 @@ module Parse
219
221
  # @return [Moneta::Transformer,Moneta::Expires] the cache instance
220
222
  # @see Parse::Client#cache
221
223
  def self.cache
222
- @shared_cache ||= Parse::Client.client(:default).cache
224
+ # Deliberately NOT memoized. `@shared_cache ||=` pinned the first default
225
+ # client's store for the life of the process, so a later `Parse.setup`
226
+ # (a re-configuration, or a test suite swapping clients between cases)
227
+ # kept handing back the previous client's cache with no way to reset it.
228
+ Parse::Client.client(:default).cache
229
+ end
230
+
231
+ # The SDK-owned store for the default client: the response cache, the
232
+ # identity and role planes, and what {Parse::Client#clear_cache!} clears.
233
+ #
234
+ # Prefer {.cache} for application keys. This exists so the SDK's own slice
235
+ # is reachable and inspectable, not as a place to put application data.
236
+ # @return [Object]
237
+ # @see Parse::Client#sdk_cache
238
+ def self.sdk_cache
239
+ Parse::Client.client(:default).sdk_cache
223
240
  end
224
241
 
225
242
  # This class is the core and low level API for the Parse SDK REST interface that
@@ -337,6 +354,38 @@ module Parse
337
354
  attr_reader :session_token
338
355
  alias_method :app_id, :application_id
339
356
 
357
+ # The store the SDK itself reads and writes: the response cache, the
358
+ # identity and role planes, and {#clear_cache!}.
359
+ #
360
+ # With `cache_keyspace: true` this is a {Parse::Cache::ScopedView} (or a
361
+ # {Parse::Cache::KeyspacedStore} for a store that cannot produce one)
362
+ # confined to this client's keyspace. Without it, this IS {#cache}, so
363
+ # nothing about an existing deployment changes.
364
+ #
365
+ # Kept separate from {#cache} because the two answer different questions.
366
+ # `cache` is the store the application configured and may still use for
367
+ # its own keys, under their original physical names. `sdk_cache` is the
368
+ # SDK's slice of it, and clearing that slice must not reach application
369
+ # data.
370
+ #
371
+ # @return [Object] the SDK-owned store.
372
+ def sdk_cache
373
+ @sdk_cache || self.cache
374
+ end
375
+
376
+ # This client's authorization context: the identity and role caches, and
377
+ # the session-token resolver that feeds every mongo-direct ACL decision.
378
+ #
379
+ # One context per client, created lazily and never shared. That is the
380
+ # point of it living here rather than in module-level state: two clients
381
+ # pointed at two Parse applications must not resolve a token against each
382
+ # other's caches, nor validate it against each other's `/users/me`.
383
+ #
384
+ # @return [Parse::Authorization::Context]
385
+ def authorization
386
+ @authorization ||= Parse::Authorization::Context.new(client: self)
387
+ end
388
+
340
389
  # Redacted inspection. The default Ruby `#inspect` would dump every ivar,
341
390
  # exposing the master key and any bound session token in cleartext wherever
342
391
  # a client is logged or surfaced in an error reporter. Show only the
@@ -411,6 +460,7 @@ module Parse
411
460
  raise ArgumentError, "Parse::Client#with_session requires a client with a bound session_token" if @session_token.nil?
412
461
  Parse.with_session(@session_token, &block)
413
462
  end
463
+
414
464
  # The client can support multiple sessions. The first session created, will be placed
415
465
  # under the default session tag. The :default session will be the default client to be used
416
466
  # by the other classes including Parse::Query and Parse::Objects
@@ -586,10 +636,10 @@ module Parse
586
636
  # explicit-nil case, putting a "non-master" client back into master mode
587
637
  # in any deployment that exports PARSE_SERVER_MASTER_KEY / PARSE_MASTER_KEY.
588
638
  @master_key = if opts.key?(:master_key)
589
- opts[:master_key]
590
- else
591
- ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
592
- end
639
+ opts[:master_key]
640
+ else
641
+ ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
642
+ end
593
643
  # Optional token bound to this client; applied per request as the
594
644
  # lowest-priority auth fallback (see #request). Normalize blank/whitespace
595
645
  # to nil so it never trips the "token present" branch at request time
@@ -754,13 +804,89 @@ module Parse
754
804
  end
755
805
 
756
806
  self.cache = opts[:cache]
757
- conn.use Parse::Middleware::Caching, self.cache, {
807
+
808
+ # Build one keyspace from the effective namespace and install it on
809
+ # both the middleware and the store, so key generation and eviction
810
+ # can never disagree. Previously the middleware could hold a
811
+ # `cache_namespace:` the store knew nothing about, and
812
+ # `clear_cache!` scoped using only the store's own namespace, so a
813
+ # namespaced client cleared every SDK key on the database.
814
+ #
815
+ # Opt-in: without `cache_keyspace: true` the legacy key shape is
816
+ # kept, so an upgrade changes nothing until an operator asks for it.
817
+ #
818
+ # `scoped` derives an immutable, per-client view rather than
819
+ # mutating the store in place. This matters when one backend
820
+ # (e.g. a `Parse::Cache::Redis` connection pool) is shared across
821
+ # more than one `Parse::Client`: mutating a shared store's
822
+ # keyspace would rebind it out from under whichever client
823
+ # configured it first, so this client's own `sdk_cache` becomes
824
+ # the view while `cache` stays the store that was configured, and
825
+ # the shared backend itself is never touched.
826
+ cache_keyspace = nil
827
+ if opts[:cache_keyspace]
828
+ cache_keyspace = Parse::Cache::Keyspace.new(
829
+ app_id: @application_id,
830
+ server_url: @server_url,
831
+ namespace: opts[:cache_namespace],
832
+ )
833
+ # Derive an immutable per-client view rather than mutating the
834
+ # backend, so two clients can share a connection pool without
835
+ # sharing ownership of the keyspace. There is deliberately no
836
+ # `keyspace=` fallback here, since reintroducing a mutable
837
+ # binding at the call site would restore the cross-client
838
+ # rebinding this replaces.
839
+ #
840
+ # A store that cannot produce a view is wrapped rather than left
841
+ # alone. Leaving it bare still composed keys correctly, because
842
+ # the middleware below receives the keyspace directly, so the
843
+ # deployment looked keyspaced; but `clear_cache!` then called the
844
+ # store's own unrestricted `clear`, which on a plain
845
+ # `Moneta.new(:Redis)` is FLUSHDB. Opting into keyspacing and
846
+ # getting a database-wide flush is the exact inversion of the
847
+ # option: it deletes other applications' entries and any
848
+ # `parse-stack:foc:v1:*` create-locks sharing the database.
849
+ #
850
+ # The view is assigned to `sdk_cache`, NOT over `cache`. An
851
+ # earlier version replaced `cache` outright, which meant the
852
+ # store an application configured and passed in was no longer
853
+ # reachable through the accessor documented for reaching it,
854
+ # and application reads and writes silently moved inside the
855
+ # SDK's keyspace. A scoped view also cannot honestly stand in
856
+ # for a complete Moneta store: `clear`, `each_key`, and `close`
857
+ # either break scope isolation or quietly change meaning.
858
+ @sdk_cache = if self.cache.respond_to?(:scoped)
859
+ self.cache.scoped(cache_keyspace)
860
+ else
861
+ Parse::Cache::KeyspacedStore.new(store: self.cache, keyspace: cache_keyspace)
862
+ end
863
+ end
864
+
865
+ # Register the invalidation triggers once a keyspace exists, so
866
+ # role and identity staleness is bounded by webhook rather than by
867
+ # application discipline. Opt-in with the keyspace, since without
868
+ # planes there is nothing to invalidate.
869
+ if cache_keyspace && sdk_cache.respond_to?(:roles) &&
870
+ opts.fetch(:cache_invalidation_hooks, true)
871
+ begin
872
+ Parse::Cache::Invalidation.install!(sdk_cache)
873
+ rescue StandardError => e
874
+ warn "[Parse::Client] cache invalidation hooks not installed: #{e.class}"
875
+ end
876
+ end
877
+
878
+ conn.use Parse::Middleware::Caching, sdk_cache, {
758
879
  expires: opts[:expires].to_i,
759
880
  # Optional `cache_namespace:` prefixes every key so two Parse
760
881
  # apps sharing one Redis don't collide on `mk:/classes/Song/abc`.
761
882
  # Explicit only — we do NOT auto-derive from app_id to keep
762
883
  # existing single-app deployments backward-compatible.
763
884
  namespace: opts[:cache_namespace],
885
+ keyspace: cache_keyspace,
886
+ # During a rolling deploy, new workers write keyspaced keys while
887
+ # old workers still read the legacy shape, so invalidation has to
888
+ # hit both until every old worker is drained.
889
+ delete_legacy_variants: opts.fetch(:cache_delete_legacy_variants, true),
764
890
  }
765
891
 
766
892
  # Inform about opt-in cache behavior
@@ -843,6 +969,7 @@ module Parse
843
969
  # env-proxy autodiscovery.
844
970
  faraday_opts[:proxy] = nil unless @allow_faraday_proxy
845
971
  end
972
+
846
973
  private :validate_faraday_opts!
847
974
 
848
975
  # Hosts considered "loopback" for the cleartext-ws:// guard in
@@ -927,10 +1054,10 @@ module Parse
927
1054
  return if unknown.empty?
928
1055
 
929
1056
  warn "[Parse::Client] Ignoring unknown live_query option(s): " \
930
- "#{unknown.inspect}. Valid keys are Parse::LiveQuery::Configuration " \
931
- "setters (url, application_id, client_key, master_key, ping_interval, " \
932
- "pong_timeout, allow_insecure, ssl_min_version, ssl_max_version, " \
933
- "logging_enabled, log_level, ...). Check for typos."
1057
+ "#{unknown.inspect}. Valid keys are Parse::LiveQuery::Configuration " \
1058
+ "setters (url, application_id, client_key, master_key, ping_interval, " \
1059
+ "pong_timeout, allow_insecure, ssl_min_version, ssl_max_version, " \
1060
+ "logging_enabled, log_level, ...). Check for typos."
934
1061
  end
935
1062
 
936
1063
  # If set, returns the current retry count for this instance. Otherwise,
@@ -946,9 +1073,20 @@ module Parse
946
1073
  @conn.url_prefix
947
1074
  end
948
1075
 
949
- # Clear the client cache
1076
+ # Clear the SDK's own cached entries.
1077
+ #
1078
+ # Operates on {#sdk_cache}, so with `cache_keyspace: true` this removes
1079
+ # only keys under this client's keyspace and leaves application keys in
1080
+ # the same store untouched. Without a keyspace `sdk_cache` IS `cache`,
1081
+ # and the call has its historical whole-store meaning.
1082
+ #
1083
+ # To clear the underlying store outright, call `client.cache.clear`
1084
+ # explicitly. On a Redis-backed store that is `FLUSHDB` and takes
1085
+ # everything on the database with it, including other applications and
1086
+ # any `parse-stack:foc:v1:*` create-locks.
950
1087
  def clear_cache!
951
- self.cache.clear if self.cache.present?
1088
+ target = sdk_cache
1089
+ target.clear if target.present?
952
1090
  end
953
1091
 
954
1092
  # No-credentials liveness probe. Hits the Parse Server health endpoint and
@@ -1049,10 +1187,10 @@ module Parse
1049
1187
  # Pre-declare locals referenced inside rescue blocks so CodeQL's
1050
1188
  # uninitialized-variable analysis is satisfied even if an exception
1051
1189
  # raises before the natural assignment site.
1052
- response = nil
1190
+ response = nil
1053
1191
  _retry_count = nil
1054
1192
  _retry_delay = nil
1055
- _request = nil
1193
+ _request = nil
1056
1194
  # Kwarg-absorption guard. The `**opts` splat in API helper methods
1057
1195
  # (lib/parse/api/*.rb) absorbs a caller-passed `opts: { ... }`
1058
1196
  # keyword as a key named `:opts` rather than as the request options
@@ -1092,193 +1230,193 @@ module Parse
1092
1230
  _retry_max ||= _retry_count
1093
1231
 
1094
1232
  begin
1095
- headers ||= {}
1096
- # if the first argument is a Parse::Request object, then construct it
1097
- _request = nil
1098
- if method.is_a?(Request)
1099
- _request = method
1100
- method = _request.method
1101
- uri ||= _request.path
1102
- query ||= _request.query
1103
- body ||= _request.body
1104
- headers.merge! _request.headers
1105
- else
1106
- _request = Parse::Request.new(method, uri, body: body, headers: headers, opts: opts)
1107
- end
1108
-
1109
- # http method
1110
- method = method.downcase.to_sym
1111
- # set the User-Agent
1112
- headers[USER_AGENT_HEADER] = USER_AGENT_VERSION
1113
-
1114
- if opts[:cache] == false
1115
- headers[Parse::Middleware::Caching::CACHE_CONTROL] = "no-cache"
1116
- elsif opts[:cache] == :write_only
1117
- # Write-only mode: skip reading from cache, but still write to cache
1118
- # Useful for fetch!/reload! which want fresh data but should update cache
1119
- headers[Parse::Middleware::Caching::CACHE_WRITE_ONLY] = "true"
1120
- elsif opts[:cache].is_a?(Numeric)
1121
- # specify the cache duration of this request
1122
- headers[Parse::Middleware::Caching::CACHE_EXPIRES_DURATION] = opts[:cache].to_s
1123
- end
1233
+ headers ||= {}
1234
+ # if the first argument is a Parse::Request object, then construct it
1235
+ _request = nil
1236
+ if method.is_a?(Request)
1237
+ _request = method
1238
+ method = _request.method
1239
+ uri ||= _request.path
1240
+ query ||= _request.query
1241
+ body ||= _request.body
1242
+ headers.merge! _request.headers
1243
+ else
1244
+ _request = Parse::Request.new(method, uri, body: body, headers: headers, opts: opts)
1245
+ end
1124
1246
 
1125
- # Resolve the auth context in three layers:
1126
- # 1. explicit per-call `use_master_key:` and `session_token:`
1127
- # 2. ambient session set by `Parse.with_session { ... }` (fiber-local)
1128
- # 3. process-wide `Parse.client_mode` flag — when true, master key is
1129
- # never sent unless the caller explicitly passed `use_master_key: true`
1130
- explicit_master = opts.key?(:use_master_key)
1131
-
1132
- if opts[:use_master_key] == false
1133
- headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1134
- elsif Parse.client_mode && opts[:use_master_key] != true
1135
- # client mode defaults master key OFF unless explicitly opted in
1136
- headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1137
- end
1247
+ # http method
1248
+ method = method.downcase.to_sym
1249
+ # set the User-Agent
1250
+ headers[USER_AGENT_HEADER] = USER_AGENT_VERSION
1251
+
1252
+ if opts[:cache] == false
1253
+ headers[Parse::Middleware::Caching::CACHE_CONTROL] = "no-cache"
1254
+ elsif opts[:cache] == :write_only
1255
+ # Write-only mode: skip reading from cache, but still write to cache
1256
+ # Useful for fetch!/reload! which want fresh data but should update cache
1257
+ headers[Parse::Middleware::Caching::CACHE_WRITE_ONLY] = "true"
1258
+ elsif opts[:cache].is_a?(Numeric)
1259
+ # specify the cache duration of this request
1260
+ headers[Parse::Middleware::Caching::CACHE_EXPIRES_DURATION] = opts[:cache].to_s
1261
+ end
1138
1262
 
1139
- raw_token = opts[:session_token]
1140
- # SEC-02: an EXPLICITLY-supplied session_token that is a blank /
1141
- # whitespace-only string is an unusable credential NOT an invitation
1142
- # to fall back to the master key. Treat it as "no credential"
1143
- # (anonymous) and fail closed: suppress the master key and send no
1144
- # session header, so Parse Server applies public ACL/CLP instead of
1145
- # silently executing with master authority. The caller passed a token
1146
- # explicitly, so we also do NOT fall through to the ambient / bound
1147
- # token — that was their stated (empty) scope. `session_token: nil`
1148
- # (value literally nil) is unchanged: it means "not set", and still
1149
- # resolves via the ambient / bound fallback below.
1150
- explicit_blank_token = raw_token.is_a?(String) && raw_token.strip.empty?
1151
- token = explicit_blank_token ? nil : raw_token
1152
- # When no explicit token was passed AND the caller didn't ask to send
1153
- # the master key, fall through to (in order) the fiber-local ambient set
1154
- # by `Parse.with_session`, then this client's own bound `@session_token`.
1155
- # Explicit `use_master_key: true` is treated as a deliberate admin call
1156
- # and skips both — otherwise an `admin.do_thing(use_master_key: true)`
1157
- # nested inside a `with_session(user)` block (or on a token-bound client)
1158
- # would silently downgrade. The ambient wins over the bound token so a
1159
- # `with_session` override inside a user-scoped client still takes effect.
1160
- if token.nil? && !explicit_blank_token && !(explicit_master && opts[:use_master_key] == true)
1161
- ambient = Parse.current_session_token
1162
- # A whitespace-only ambient must not count as present: otherwise it
1163
- # blocks the bound-token fallback below and then fails the later
1164
- # `token.present?` check, silently sending the master key instead.
1165
- token = ambient if ambient.is_a?(String) && !ambient.strip.empty?
1166
- token = @session_token if (token.nil? || token.to_s.strip.empty?) && @session_token
1167
- end
1168
- if explicit_blank_token
1169
- # Fail closed: never send the master key for an unusable explicit token.
1170
- headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1171
- elsif token.present?
1172
- token = token.session_token if token.respond_to?(:session_token)
1173
- headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1174
- headers[Parse::Protocol::SESSION_TOKEN] = token
1175
- end
1263
+ # Resolve the auth context in three layers:
1264
+ # 1. explicit per-call `use_master_key:` and `session_token:`
1265
+ # 2. ambient session set by `Parse.with_session { ... }` (fiber-local)
1266
+ # 3. process-wide `Parse.client_mode` flag when true, master key is
1267
+ # never sent unless the caller explicitly passed `use_master_key: true`
1268
+ explicit_master = opts.key?(:use_master_key)
1269
+
1270
+ if opts[:use_master_key] == false
1271
+ headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1272
+ elsif Parse.client_mode && opts[:use_master_key] != true
1273
+ # client mode defaults master key OFF unless explicitly opted in
1274
+ headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1275
+ end
1176
1276
 
1177
- #if it is a :get request, then use query params, otherwise body.
1178
- params = (method == :get ? query : body) || {}
1179
- # if the path does not start with the '/1/' prefix, then add it to be nice.
1180
- # actually send the request and return the body
1181
- response_env = @conn.send(method, uri, params, headers)
1182
- response = response_env.body
1183
- response.request = _request
1184
-
1185
- case response.http_status
1186
- when 401, 403
1187
- Parse::Client._safe_warn("AuthenticationError", response)
1188
- raise Parse::Error::AuthenticationError, response
1189
- when 400, 408
1190
- if response.code == Parse::Response::ERROR_TIMEOUT || response.code == 143 #"net/http: timeout awaiting response headers"
1191
- Parse::Client._safe_warn("TimeoutError", response)
1192
- raise Parse::Error::TimeoutError, response
1277
+ raw_token = opts[:session_token]
1278
+ # SEC-02: an EXPLICITLY-supplied session_token that is a blank /
1279
+ # whitespace-only string is an unusable credential NOT an invitation
1280
+ # to fall back to the master key. Treat it as "no credential"
1281
+ # (anonymous) and fail closed: suppress the master key and send no
1282
+ # session header, so Parse Server applies public ACL/CLP instead of
1283
+ # silently executing with master authority. The caller passed a token
1284
+ # explicitly, so we also do NOT fall through to the ambient / bound
1285
+ # token — that was their stated (empty) scope. `session_token: nil`
1286
+ # (value literally nil) is unchanged: it means "not set", and still
1287
+ # resolves via the ambient / bound fallback below.
1288
+ explicit_blank_token = raw_token.is_a?(String) && raw_token.strip.empty?
1289
+ token = explicit_blank_token ? nil : raw_token
1290
+ # When no explicit token was passed AND the caller didn't ask to send
1291
+ # the master key, fall through to (in order) the fiber-local ambient set
1292
+ # by `Parse.with_session`, then this client's own bound `@session_token`.
1293
+ # Explicit `use_master_key: true` is treated as a deliberate admin call
1294
+ # and skips both — otherwise an `admin.do_thing(use_master_key: true)`
1295
+ # nested inside a `with_session(user)` block (or on a token-bound client)
1296
+ # would silently downgrade. The ambient wins over the bound token so a
1297
+ # `with_session` override inside a user-scoped client still takes effect.
1298
+ if token.nil? && !explicit_blank_token && !(explicit_master && opts[:use_master_key] == true)
1299
+ ambient = Parse.current_session_token
1300
+ # A whitespace-only ambient must not count as present: otherwise it
1301
+ # blocks the bound-token fallback below and then fails the later
1302
+ # `token.present?` check, silently sending the master key instead.
1303
+ token = ambient if ambient.is_a?(String) && !ambient.strip.empty?
1304
+ token = @session_token if (token.nil? || token.to_s.strip.empty?) && @session_token
1193
1305
  end
1194
- when 404
1195
- unless response.object_not_found?
1196
- Parse::Client._safe_warn("ConnectionError", response)
1197
- raise Parse::Error::ConnectionError, response
1306
+ if explicit_blank_token
1307
+ # Fail closed: never send the master key for an unusable explicit token.
1308
+ headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1309
+ elsif token.present?
1310
+ token = token.session_token if token.respond_to?(:session_token)
1311
+ headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1312
+ headers[Parse::Protocol::SESSION_TOKEN] = token
1198
1313
  end
1199
- when 405, 406
1200
- Parse::Client._safe_warn("ProtocolError", response)
1201
- raise Parse::Error::ProtocolError, response
1202
- when 429 # Request over the throttle limit
1203
- Parse::Client._safe_warn("RequestLimitExceededError", response)
1204
- raise Parse::Error::RequestLimitExceededError, response
1205
- when 500, 503
1206
- Parse::Client._safe_warn("ServiceUnavailableError", response)
1207
- raise Parse::Error::ServiceUnavailableError, response
1208
- end
1209
1314
 
1210
- if response.error?
1211
- if response.code <= Parse::Response::ERROR_SERVICE_UNAVAILABLE
1212
- Parse::Client._safe_warn("ServiceUnavailableError", response)
1213
- raise Parse::Error::ServiceUnavailableError, response
1214
- elsif response.code <= 100
1215
- Parse::Client._safe_warn("ServerError", response)
1216
- raise Parse::Error::ServerError, response
1217
- elsif response.code == Parse::Response::ERROR_EXCEEDED_BURST_LIMIT
1315
+ #if it is a :get request, then use query params, otherwise body.
1316
+ params = (method == :get ? query : body) || {}
1317
+ # if the path does not start with the '/1/' prefix, then add it to be nice.
1318
+ # actually send the request and return the body
1319
+ response_env = @conn.send(method, uri, params, headers)
1320
+ response = response_env.body
1321
+ response.request = _request
1322
+
1323
+ case response.http_status
1324
+ when 401, 403
1325
+ Parse::Client._safe_warn("AuthenticationError", response)
1326
+ raise Parse::Error::AuthenticationError, response
1327
+ when 400, 408
1328
+ if response.code == Parse::Response::ERROR_TIMEOUT || response.code == 143 #"net/http: timeout awaiting response headers"
1329
+ Parse::Client._safe_warn("TimeoutError", response)
1330
+ raise Parse::Error::TimeoutError, response
1331
+ end
1332
+ when 404
1333
+ unless response.object_not_found?
1334
+ Parse::Client._safe_warn("ConnectionError", response)
1335
+ raise Parse::Error::ConnectionError, response
1336
+ end
1337
+ when 405, 406
1338
+ Parse::Client._safe_warn("ProtocolError", response)
1339
+ raise Parse::Error::ProtocolError, response
1340
+ when 429 # Request over the throttle limit
1218
1341
  Parse::Client._safe_warn("RequestLimitExceededError", response)
1219
1342
  raise Parse::Error::RequestLimitExceededError, response
1220
- elsif response.code == 209 # Error 209: invalid session token
1221
- Parse::Client._safe_warn("InvalidSessionTokenError", response)
1222
- raise Parse::Error::InvalidSessionTokenError, response
1223
- elsif response.code == Parse::Response::ERROR_DUPLICATE_REQUEST # 159
1224
- # Request-id idempotency rejected a duplicate — the original write
1225
- # already applied (NOT a second time). Surface a typed, catchable
1226
- # signal rather than a generic error; this is what a transparently-
1227
- # retried write that landed-but-lost-its-response sees on the replay.
1228
- Parse::Client._safe_warn("DuplicateRequestError", response)
1229
- raise Parse::Error::DuplicateRequestError, response
1343
+ when 500, 503
1344
+ Parse::Client._safe_warn("ServiceUnavailableError", response)
1345
+ raise Parse::Error::ServiceUnavailableError, response
1230
1346
  end
1231
- end
1232
1347
 
1233
- response
1234
- rescue Parse::Error::RequestLimitExceededError, Parse::Error::ServiceUnavailableError => e
1235
- # 429 (RequestLimitExceeded): the server threw the request away, so
1236
- # re-sending is safe for any method. 500/503 (ServiceUnavailable) is
1237
- # ambiguous a write may have applied before the error — so only
1238
- # re-send when the request is idempotent (see #idempotent_retry?).
1239
- retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers)
1240
- if _retry_count > 0 && retryable
1241
- warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response.request}"
1242
- _retry_count -= 1
1243
- # Use Retry-After header if available, otherwise use linear backoff
1244
- retry_after = response.retry_after if response.respond_to?(:retry_after)
1245
- if retry_after && retry_after > 0
1246
- _retry_delay = retry_after
1247
- warn "[Parse:Retry] Using Retry-After header: #{_retry_delay}s"
1248
- else
1249
- # Linear backoff (RETRY_DELAY × attempt number) with +/-25% jitter.
1250
- # Never zero
1251
- # zero-wait retries amplify DoS against upstream and stampede on 429.
1348
+ if response.error?
1349
+ if response.code <= Parse::Response::ERROR_SERVICE_UNAVAILABLE
1350
+ Parse::Client._safe_warn("ServiceUnavailableError", response)
1351
+ raise Parse::Error::ServiceUnavailableError, response
1352
+ elsif response.code <= 100
1353
+ Parse::Client._safe_warn("ServerError", response)
1354
+ raise Parse::Error::ServerError, response
1355
+ elsif response.code == Parse::Response::ERROR_EXCEEDED_BURST_LIMIT
1356
+ Parse::Client._safe_warn("RequestLimitExceededError", response)
1357
+ raise Parse::Error::RequestLimitExceededError, response
1358
+ elsif response.code == 209 # Error 209: invalid session token
1359
+ Parse::Client._safe_warn("InvalidSessionTokenError", response)
1360
+ raise Parse::Error::InvalidSessionTokenError, response
1361
+ elsif response.code == Parse::Response::ERROR_DUPLICATE_REQUEST # 159
1362
+ # Request-id idempotency rejected a duplicate — the original write
1363
+ # already applied (NOT a second time). Surface a typed, catchable
1364
+ # signal rather than a generic error; this is what a transparently-
1365
+ # retried write that landed-but-lost-its-response sees on the replay.
1366
+ Parse::Client._safe_warn("DuplicateRequestError", response)
1367
+ raise Parse::Error::DuplicateRequestError, response
1368
+ end
1369
+ end
1370
+
1371
+ response
1372
+ rescue Parse::Error::RequestLimitExceededError, Parse::Error::ServiceUnavailableError => e
1373
+ # 429 (RequestLimitExceeded): the server threw the request away, so
1374
+ # re-sending is safe for any method. 500/503 (ServiceUnavailable) is
1375
+ # ambiguous — a write may have applied before the error — so only
1376
+ # re-send when the request is idempotent (see #idempotent_retry?).
1377
+ retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers)
1378
+ if _retry_count > 0 && retryable
1379
+ warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response.request}"
1380
+ _retry_count -= 1
1381
+ # Use Retry-After header if available, otherwise use linear backoff
1382
+ retry_after = response.retry_after if response.respond_to?(:retry_after)
1383
+ if retry_after && retry_after > 0
1384
+ _retry_delay = retry_after
1385
+ warn "[Parse:Retry] Using Retry-After header: #{_retry_delay}s"
1386
+ else
1387
+ # Linear backoff (RETRY_DELAY × attempt number) with +/-25% jitter.
1388
+ # Never zero —
1389
+ # zero-wait retries amplify DoS against upstream and stampede on 429.
1390
+ backoff_delay = RETRY_DELAY * (_retry_max - _retry_count)
1391
+ _retry_delay = backoff_delay * (0.75 + rand * 0.5)
1392
+ end
1393
+ sleep _retry_delay if _retry_delay > 0
1394
+ retry
1395
+ end
1396
+ raise
1397
+ rescue Faraday::ClientError, Faraday::TimeoutError, Net::OpenTimeout => e
1398
+ # Request timed out mid-flight: the outcome is unknown (the server may
1399
+ # have received and applied the write but never answered), so only
1400
+ # re-send idempotent requests to avoid double-applying.
1401
+ #
1402
+ # Faraday 2.x raises `Faraday::TimeoutError` for a read timeout
1403
+ # (`Timeout::Error` / `Errno::ETIMEDOUT`); it subclasses `Faraday::Error`,
1404
+ # not `ClientError`, so it must be listed explicitly to be caught. We
1405
+ # deliberately do NOT catch `Faraday::ConnectionFailed` (connection
1406
+ # refused/reset, plus the wrapped connect-timeout): refused is a
1407
+ # non-transient "server down / misconfigured" failure, and auto-retrying
1408
+ # it only adds backoff latency before the inevitable error. Broadening to
1409
+ # reset connections safely (retry reset, fail fast on refused) is tracked
1410
+ # as a follow-up.
1411
+ if _retry_count > 0 && idempotent_retry?(method, body, headers)
1412
+ warn "[Parse:Retry] Retries remaining #{_retry_count} : #{_request}"
1413
+ _retry_count -= 1
1252
1414
  backoff_delay = RETRY_DELAY * (_retry_max - _retry_count)
1253
1415
  _retry_delay = backoff_delay * (0.75 + rand * 0.5)
1416
+ sleep _retry_delay if _retry_delay > 0
1417
+ retry
1254
1418
  end
1255
- sleep _retry_delay if _retry_delay > 0
1256
- retry
1257
- end
1258
- raise
1259
- rescue Faraday::ClientError, Faraday::TimeoutError, Net::OpenTimeout => e
1260
- # Request timed out mid-flight: the outcome is unknown (the server may
1261
- # have received and applied the write but never answered), so only
1262
- # re-send idempotent requests to avoid double-applying.
1263
- #
1264
- # Faraday 2.x raises `Faraday::TimeoutError` for a read timeout
1265
- # (`Timeout::Error` / `Errno::ETIMEDOUT`); it subclasses `Faraday::Error`,
1266
- # not `ClientError`, so it must be listed explicitly to be caught. We
1267
- # deliberately do NOT catch `Faraday::ConnectionFailed` (connection
1268
- # refused/reset, plus the wrapped connect-timeout): refused is a
1269
- # non-transient "server down / misconfigured" failure, and auto-retrying
1270
- # it only adds backoff latency before the inevitable error. Broadening to
1271
- # reset connections safely (retry reset, fail fast on refused) is tracked
1272
- # as a follow-up.
1273
- if _retry_count > 0 && idempotent_retry?(method, body, headers)
1274
- warn "[Parse:Retry] Retries remaining #{_retry_count} : #{_request}"
1275
- _retry_count -= 1
1276
- backoff_delay = RETRY_DELAY * (_retry_max - _retry_count)
1277
- _retry_delay = backoff_delay * (0.75 + rand * 0.5)
1278
- sleep _retry_delay if _retry_delay > 0
1279
- retry
1280
- end
1281
- raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
1419
+ raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
1282
1420
  end
1283
1421
  end
1284
1422
 
@@ -1488,7 +1626,7 @@ module Parse
1488
1626
  when Hash
1489
1627
  type = value["__type"] || value[:__type]
1490
1628
  class_name = value["className"] || value[:className]
1491
- object_id = value["objectId"] || value[:objectId]
1629
+ object_id = value["objectId"] || value[:objectId]
1492
1630
  if type == Parse::Model::TYPE_POINTER && class_name && object_id
1493
1631
  # Pointers carry no attributes, so building one is lossless even for
1494
1632
  # an unregistered class (yields a Parse::Pointer).