parse-stack-next 5.7.6 → 5.8.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +830 -0
- data/README.md +14 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +317 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +93 -1
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +21 -3
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +72 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
|
@@ -346,15 +346,36 @@ module Parse
|
|
|
346
346
|
puts "[[Response]] --------------------------------------\n"
|
|
347
347
|
end
|
|
348
348
|
|
|
349
|
+
parsed = true
|
|
349
350
|
begin
|
|
350
351
|
r = Parse::Response.new(response_env.body)
|
|
351
352
|
rescue => e
|
|
353
|
+
parsed = false
|
|
352
354
|
r = Parse::Response.new
|
|
353
355
|
r.code = response_env.status
|
|
354
356
|
r.error = "Invalid response for #{env[:method]} #{env[:url]}: #{e}"
|
|
355
357
|
end
|
|
358
|
+
status = response_env[:status].to_i
|
|
356
359
|
r.http_status = response_env[:status]
|
|
357
360
|
r.headers = response_env[:response_headers]
|
|
361
|
+
if parsed && status.between?(200, 299)
|
|
362
|
+
# Parse Server never reports an error with a 2xx status, so a
|
|
363
|
+
# `code` or `error` key in a 2xx body is object data (for example
|
|
364
|
+
# a column set by a beforeSave trigger), not an error envelope.
|
|
365
|
+
r.code = nil
|
|
366
|
+
r.error = nil
|
|
367
|
+
elsif status > 0 && !status.between?(200, 299) && r.error.blank?
|
|
368
|
+
# A non-2xx response is a failure even without a Parse `error`
|
|
369
|
+
# field. It may come from something in front of Parse Server (an
|
|
370
|
+
# API gateway or load balancer returning 502/504 with
|
|
371
|
+
# `{"message": ...}`), or from Parse Server itself (a failed
|
|
372
|
+
# transaction returns `{"code": 1, "message": ...}`). Record the
|
|
373
|
+
# HTTP status as the code when there is none, so the request is
|
|
374
|
+
# never mistaken for a success.
|
|
375
|
+
detail = r.result.is_a?(Hash) ? r.result["message"] : nil
|
|
376
|
+
r.code ||= status
|
|
377
|
+
r.error = detail.present? ? detail.to_s : "HTTP #{status} response without a Parse error body"
|
|
378
|
+
end
|
|
358
379
|
r.code ||= response_env[:status] if r.error.present?
|
|
359
380
|
response_env[:body] = r
|
|
360
381
|
end
|
data/lib/parse/client/caching.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "faraday"
|
|
|
5
5
|
require "moneta"
|
|
6
6
|
require "connection_pool"
|
|
7
7
|
require "digest"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "json"
|
|
8
10
|
require_relative "protocol"
|
|
9
11
|
|
|
10
12
|
module Parse
|
|
@@ -38,6 +40,29 @@ module Parse
|
|
|
38
40
|
CACHE_EXPIRES_DURATION = "X-Parse-Stack-Cache-Expires"
|
|
39
41
|
# Header in request to enable write-only cache mode (skip read, still write)
|
|
40
42
|
CACHE_WRITE_ONLY = "X-Parse-Stack-Cache-Write-Only"
|
|
43
|
+
# Paths whose responses are never cached. `users/me` and `sessions/me`
|
|
44
|
+
# answer "who owns this token", so a cached copy keeps a logged-out or
|
|
45
|
+
# revoked token resolving to its user until the entry expires. The
|
|
46
|
+
# credential endpoints carry a password and must never be stored.
|
|
47
|
+
UNCACHEABLE_PATH_RE = %r{/(?:users/me|sessions/me|login|verifyPassword|logout)/?\z}.freeze
|
|
48
|
+
# Prefix of the per-resource version keys. See {#version_keys}.
|
|
49
|
+
LEGACY_VERSION_PREFIX = "rv"
|
|
50
|
+
# Prefix of the per-class version keys folded into collection reads
|
|
51
|
+
# (queries, aggregates). See {#version_keys}.
|
|
52
|
+
CLASS_VERSION_PREFIX = "cv"
|
|
53
|
+
# Request header BodyBuilder sets when it re-sends a long GET as a POST.
|
|
54
|
+
METHOD_OVERRIDE = "X-Http-Method-Override"
|
|
55
|
+
# Paths that name a Parse class, optionally followed by an object id.
|
|
56
|
+
CLASS_PATH_RE = %r{(?:\A|/)(?:classes|aggregate|purge|schemas)/([^/]+)(?:/([^/]+))?/?\z}.freeze
|
|
57
|
+
# Built-in class endpoints, optionally followed by an object id.
|
|
58
|
+
SYSTEM_CLASS_PATH_RE = %r{(?:\A|/)(users|roles|installations|sessions)(?:/([^/]+))?/?\z}.freeze
|
|
59
|
+
# Class names for the built-in class endpoints.
|
|
60
|
+
SYSTEM_CLASSES = {
|
|
61
|
+
"users" => "_User", "roles" => "_Role",
|
|
62
|
+
"installations" => "_Installation", "sessions" => "_Session",
|
|
63
|
+
}.freeze
|
|
64
|
+
# Path of the batch endpoint, whose body names the resources it writes.
|
|
65
|
+
BATCH_PATH_RE = %r{(?:\A|/)batch/?\z}.freeze
|
|
41
66
|
|
|
42
67
|
class << self
|
|
43
68
|
# @!attribute enabled
|
|
@@ -150,6 +175,17 @@ module Parse
|
|
|
150
175
|
|
|
151
176
|
url = env.url
|
|
152
177
|
method = env.method
|
|
178
|
+
|
|
179
|
+
# Identity and credential endpoints are passthrough: never read, never
|
|
180
|
+
# stored. See UNCACHEABLE_PATH_RE.
|
|
181
|
+
return @app.call(env) if url.path.to_s.match?(UNCACHEABLE_PATH_RE)
|
|
182
|
+
|
|
183
|
+
# A long query that BodyBuilder re-sent as a POST with a GET method
|
|
184
|
+
# override is a read. It is never cached (only GETs are), and treating
|
|
185
|
+
# it as a write would retire every cached query of its class each
|
|
186
|
+
# time a long query ran.
|
|
187
|
+
return @app.call(env) if method != :get && @request_headers[METHOD_OVERRIDE].to_s.casecmp?("GET")
|
|
188
|
+
|
|
153
189
|
@cache_key = url.to_s
|
|
154
190
|
|
|
155
191
|
# Auth discriminator. A master-key request bypasses ACL, CLP and
|
|
@@ -158,16 +194,33 @@ module Parse
|
|
|
158
194
|
# protectedFields entity rules and row ACLs. These must never share a
|
|
159
195
|
# cache entry or the cache would hand privileged fields to an
|
|
160
196
|
# unprivileged caller.
|
|
197
|
+
#
|
|
198
|
+
# Both layouts also bind every key to the application id and the
|
|
199
|
+
# credential that produced it (`@legacy_auth`, see {#versioned_key}).
|
|
200
|
+
# Without that, a client configured with a different application id
|
|
201
|
+
# or a wrong master/REST key, sharing the same store, was served
|
|
202
|
+
# another application's cached private rows: the URL alone matched.
|
|
203
|
+
# `@old_shape_key` is the pre-credential key shape, kept only so a
|
|
204
|
+
# write still evicts entries written by older SDK versions during a
|
|
205
|
+
# rolling deploy.
|
|
161
206
|
@cache_auth = :anon
|
|
207
|
+
app_id = @request_headers[APP_ID].to_s
|
|
162
208
|
if @request_headers.key?(SESSION_TOKEN)
|
|
163
209
|
@session_token = @request_headers[SESSION_TOKEN]
|
|
164
210
|
hashed_token = Digest::SHA256.hexdigest(@session_token.to_s)[0, 32]
|
|
165
211
|
@cache_auth = hashed_token
|
|
166
|
-
@
|
|
212
|
+
@old_shape_key = "#{hashed_token}:#{@cache_key}" # prefix with hashed token
|
|
213
|
+
@legacy_auth = "s:#{credential_digest(app_id, @session_token)}"
|
|
167
214
|
elsif @request_headers.key?(MASTER_KEY)
|
|
168
215
|
@cache_auth = :master
|
|
169
|
-
@
|
|
216
|
+
@old_shape_key = "mk:#{@cache_key}" # prefix for master key requests
|
|
217
|
+
@legacy_auth = "mk:#{credential_digest(app_id, @request_headers[MASTER_KEY])}"
|
|
218
|
+
else
|
|
219
|
+
@old_shape_key = @cache_key
|
|
220
|
+
@legacy_auth = "an:#{credential_digest(app_id, @request_headers[API_KEY])}"
|
|
170
221
|
end
|
|
222
|
+
@cache_key = "#{@legacy_auth}:#{@cache_key}"
|
|
223
|
+
@app_id = app_id
|
|
171
224
|
|
|
172
225
|
# Optional ambient cache-tenant scope from `Parse.with_cache_tenant`.
|
|
173
226
|
# When present, composes between the configured namespace and the
|
|
@@ -180,22 +233,43 @@ module Parse
|
|
|
180
233
|
# tenant feature don't accidentally re-hydrate into a tenanted
|
|
181
234
|
# request and vice versa.
|
|
182
235
|
@cache_tenant = Parse.respond_to?(:current_cache_tenant) ? Parse.current_cache_tenant : nil
|
|
183
|
-
|
|
236
|
+
if @cache_tenant
|
|
237
|
+
@cache_key = "T:#{@cache_tenant}:#{@cache_key}"
|
|
238
|
+
@old_shape_key = "T:#{@cache_tenant}:#{@old_shape_key}"
|
|
239
|
+
end
|
|
184
240
|
|
|
185
241
|
# Namespace outermost so a SCAN over `<namespace>:*` evicts a whole
|
|
186
242
|
# tenant/app cleanly without touching another app's entries.
|
|
187
|
-
|
|
243
|
+
if @namespace
|
|
244
|
+
@cache_key = "#{@namespace}:#{@cache_key}"
|
|
245
|
+
@old_shape_key = "#{@namespace}:#{@old_shape_key}"
|
|
246
|
+
end
|
|
188
247
|
|
|
189
|
-
# Keep the legacy key for
|
|
190
|
-
#
|
|
248
|
+
# Keep the legacy key base for versioning below. The live key is
|
|
249
|
+
# built by {#versioned_key} once the versions are known, in the
|
|
250
|
+
# keyspace form when one is configured.
|
|
191
251
|
@legacy_cache_key = @cache_key
|
|
192
|
-
|
|
193
|
-
@cache_key = @keyspace.cache_key(url, auth: @cache_auth, tenant: @cache_tenant)
|
|
194
|
-
end
|
|
252
|
+
@cache_key = nil
|
|
195
253
|
|
|
196
254
|
url_path = url.path
|
|
197
255
|
|
|
198
256
|
begin
|
|
257
|
+
# Resolve the current versions and fold them into the key, in both
|
|
258
|
+
# layouts. The resource version retires every credential variant
|
|
259
|
+
# of a resource on a write to it; collection reads also carry the
|
|
260
|
+
# class version, which any write to the class replaces (see
|
|
261
|
+
# {#version_keys}).
|
|
262
|
+
#
|
|
263
|
+
# A read establishes its versions BEFORE the request goes out,
|
|
264
|
+
# creating any that are missing, and later stores its response
|
|
265
|
+
# only under those. Resolving them after the response instead let
|
|
266
|
+
# a read that started before a write but finished after it adopt
|
|
267
|
+
# the versions the write had just created and store its older
|
|
268
|
+
# body under them, so a revoked row was served to every later
|
|
269
|
+
# reader. A write only reads them: `nil` means a version does not
|
|
270
|
+
# exist yet, so no entry bound to it can be live.
|
|
271
|
+
@versions = method == :get ? establish_versions(url) : read_versions(url)
|
|
272
|
+
@cache_key = @versions ? versioned_key(url, @versions) : nil
|
|
199
273
|
# Skip cache read if write_only mode is enabled
|
|
200
274
|
if method == :get && @cache_key.present? && !@write_only && @store.key?(@cache_key)
|
|
201
275
|
# Debug-log the URL **path only** — `url.to_s` would include the
|
|
@@ -241,7 +315,7 @@ module Parse
|
|
|
241
315
|
delete_cache_variants(url)
|
|
242
316
|
instrument_cache(:miss, method: method, url_path: url_path, reason: :empty_payload)
|
|
243
317
|
end
|
|
244
|
-
elsif method == :get &&
|
|
318
|
+
elsif method == :get && !@write_only
|
|
245
319
|
# GET miss: opportunistically clear any sibling variants of the
|
|
246
320
|
# current namespace (anonymous `<url>` and master-key `mk:<url>`
|
|
247
321
|
# under the same namespace) so a stale variant from a prior
|
|
@@ -255,13 +329,14 @@ module Parse
|
|
|
255
329
|
# should evict those once at upgrade time via SCAN.
|
|
256
330
|
delete_cache_variants(url)
|
|
257
331
|
instrument_cache(:miss, method: method, url_path: url_path)
|
|
258
|
-
elsif method == :get && @
|
|
332
|
+
elsif method == :get && @write_only
|
|
259
333
|
delete_cache_variants(url)
|
|
260
334
|
instrument_cache(:miss, method: method, url_path: url_path, reason: :write_only)
|
|
261
|
-
elsif
|
|
335
|
+
elsif method != :get
|
|
262
336
|
#non GET requets should clear the cache for that same resource path.
|
|
263
337
|
#ex. a POST to /1/classes/Artist/<objectId> should delete the cache for a GET
|
|
264
338
|
# request for the same '/1/classes/Artist/<objectId>' where objectId are equivalent
|
|
339
|
+
@write_targets = write_targets(env, url)
|
|
265
340
|
delete_cache_variants(url, resource: true)
|
|
266
341
|
instrument_cache(:delete, method: method, url_path: url_path)
|
|
267
342
|
end
|
|
@@ -271,8 +346,7 @@ module Parse
|
|
|
271
346
|
# under Redis Cluster. Without it those escape the middleware and turn a
|
|
272
347
|
# cache problem into a failed application request, which inverts the
|
|
273
348
|
# whole point of the cache being optional.
|
|
274
|
-
rescue
|
|
275
|
-
Redis::CommandError, ConnectionPool::TimeoutError => e
|
|
349
|
+
rescue *cache_store_errors => e
|
|
276
350
|
# if the cache store fails to connect, catch the exception but proceed
|
|
277
351
|
# with the regular request, but turn off caching for this request. It is possible
|
|
278
352
|
# that the cache connection resumes at a later point, so this is temporary.
|
|
@@ -289,27 +363,78 @@ module Parse
|
|
|
289
363
|
response_env.body.present? && response_env.response_headers[CONTENT_LENGTH_KEY].to_i.between?(20, 1_250_000)
|
|
290
364
|
store_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
291
365
|
begin
|
|
292
|
-
# Store
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
366
|
+
# Store only under the versions established before dispatch,
|
|
367
|
+
# and only if none of them changed while the request was in
|
|
368
|
+
# flight. A change means a write landed during the request, so
|
|
369
|
+
# this body may predate it (a row whose ACL was just revoked,
|
|
370
|
+
# for instance) and must not be cached under any version.
|
|
371
|
+
if @versions && @cache_key && read_versions(url) == @versions
|
|
372
|
+
# Store with string keys (and a plain Hash of headers) so the
|
|
373
|
+
# value round-trips losslessly through the Redis cache
|
|
374
|
+
# wrapper's JSON serialization. The read path above reads
|
|
375
|
+
# string keys first with a symbol-key fallback for legacy
|
|
376
|
+
# entries.
|
|
377
|
+
@store.store(@cache_key,
|
|
378
|
+
{ "headers" => response_env.response_headers.to_h, "body" => response_env.body },
|
|
379
|
+
expires: @expires)
|
|
380
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - store_start) * 1000.0).round(3)
|
|
381
|
+
instrument_cache(:store, method: method, url_path: url_path, duration_ms: duration_ms)
|
|
382
|
+
elsif self.class.logging.present?
|
|
383
|
+
puts("[Parse::Cache] Skip store, version changed in flight >> #{url_path}")
|
|
384
|
+
end
|
|
301
385
|
rescue => e
|
|
302
386
|
puts "[Parse::Cache] Store Error: #{e.class.name}"
|
|
303
387
|
instrument_cache(:error, method: method, url_path: url_path, error: e.class.name)
|
|
304
388
|
end
|
|
305
389
|
end # if
|
|
306
|
-
|
|
307
|
-
#
|
|
390
|
+
|
|
391
|
+
# Retire the written resources again once the server has applied
|
|
392
|
+
# the write. A reader that established the versions the pre-write
|
|
393
|
+
# bump created, and then fetched the pre-write state, would
|
|
394
|
+
# otherwise cache it under live versions; a row whose ACL was just
|
|
395
|
+
# revoked would then stay readable until the entry expired. The
|
|
396
|
+
# second bump makes that reader's versions stale, so its in-flight
|
|
397
|
+
# check skips the store, or its stored entry becomes unreachable.
|
|
398
|
+
if @write_targets
|
|
399
|
+
begin
|
|
400
|
+
bump_versions(url, @write_targets)
|
|
401
|
+
rescue => e
|
|
402
|
+
puts "[Parse::Cache] Error: #{e.class.name}"
|
|
403
|
+
instrument_cache(:error, method: method, url_path: url_path, error: e.class.name)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
308
406
|
end
|
|
309
407
|
end
|
|
310
408
|
|
|
311
409
|
private
|
|
312
410
|
|
|
411
|
+
# Store errors that disable caching for the request rather than fail
|
|
412
|
+
# it. The Redis and connection_pool classes are listed only when those
|
|
413
|
+
# libraries are loaded: naming an unloaded constant in a `rescue`
|
|
414
|
+
# raises NameError while the rescue is evaluated, so a TypeError from a
|
|
415
|
+
# memory or other non-Redis store would escape as
|
|
416
|
+
# "uninitialized constant Redis" instead of falling back.
|
|
417
|
+
#
|
|
418
|
+
# @return [Array<Class>]
|
|
419
|
+
def cache_store_errors
|
|
420
|
+
errors = [::TypeError, Errno::EINVAL, Errno::ECONNREFUSED]
|
|
421
|
+
if defined?(::Redis)
|
|
422
|
+
# In redis-rb 5, CannotConnectError, TimeoutError and
|
|
423
|
+
# ConnectionError are siblings under BaseConnectionError, and
|
|
424
|
+
# ReadOnlyError (a replica after failover) sits under CommandError.
|
|
425
|
+
# Each name is checked on its own because older releases lack some.
|
|
426
|
+
%w[BaseConnectionError CannotConnectError TimeoutError ConnectionError
|
|
427
|
+
CommandError ReadOnlyError].each do |name|
|
|
428
|
+
errors << ::Redis.const_get(name) if ::Redis.const_defined?(name, false)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
# redis-rb 5 is built on redis-client, whose errors can surface
|
|
432
|
+
# unwrapped from some code paths.
|
|
433
|
+
errors << ::RedisClient::Error if defined?(::RedisClient::Error)
|
|
434
|
+
errors << ::ConnectionPool::TimeoutError if defined?(::ConnectionPool::TimeoutError)
|
|
435
|
+
errors.uniq
|
|
436
|
+
end
|
|
437
|
+
|
|
313
438
|
# Emit an ActiveSupport::Notifications event under the `parse.cache.*`
|
|
314
439
|
# namespace.
|
|
315
440
|
#
|
|
@@ -360,7 +485,220 @@ module Parse
|
|
|
360
485
|
def delete_cache_variants(url, resource: false)
|
|
361
486
|
delete_keyspace_variants(url) if resource && @keyspace
|
|
362
487
|
delete_legacy_variants(url) if legacy_variants?
|
|
363
|
-
@store.delete @cache_key # final key
|
|
488
|
+
@store.delete @cache_key if @cache_key # final key
|
|
489
|
+
# A write replaces the versions of everything it touched, which
|
|
490
|
+
# retires every credential variant in either layout: other sessions'
|
|
491
|
+
# entries included, which a delete cannot name. This is what stops a
|
|
492
|
+
# user whose read access was just revoked from reading the cached
|
|
493
|
+
# copy, directly or through a cached query over the class.
|
|
494
|
+
bump_versions(url, @write_targets || [url.path]) if resource
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Digest binding a legacy cache key to the application and the
|
|
498
|
+
# credential that authorized the request. Truncated SHA-256: the key is
|
|
499
|
+
# never a credential, only a discriminator.
|
|
500
|
+
# @!visibility private
|
|
501
|
+
def credential_digest(app_id, credential)
|
|
502
|
+
Digest::SHA256.hexdigest("#{app_id}\x00#{credential}")[0, 32]
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Version keys a read of this URL is bound to: the resource version
|
|
506
|
+
# for its path and, for a collection read (a query, an aggregate, or a
|
|
507
|
+
# list of a built-in class), the version of the class.
|
|
508
|
+
#
|
|
509
|
+
# The resource version is keyed by application and URL path (no query
|
|
510
|
+
# string), and NOT by credential or tenant, so a write through any
|
|
511
|
+
# caller retires the entries of every caller. Using the path means a
|
|
512
|
+
# write to `classes/Post/abc` also retires `classes/Post/abc?include=...`.
|
|
513
|
+
#
|
|
514
|
+
# The class version is what retires cached queries. A query result for
|
|
515
|
+
# `classes/Post?where=...` can contain any Post, so a write to one
|
|
516
|
+
# (an update, an ACL change, a delete, a create, or a batch touching
|
|
517
|
+
# the class) replaces the class version and every cached Post query
|
|
518
|
+
# becomes unreachable. Single-object reads do not carry it, so a write
|
|
519
|
+
# to one object leaves other objects' cached reads alone.
|
|
520
|
+
# @!visibility private
|
|
521
|
+
# @return [Array<String>]
|
|
522
|
+
def version_keys(url)
|
|
523
|
+
keys = [version_key(LEGACY_VERSION_PREFIX, "#{url_origin(url)}#{url.path}")]
|
|
524
|
+
class_name, object_id = class_scope(url.path)
|
|
525
|
+
if class_name && object_id.nil?
|
|
526
|
+
keys << version_key(CLASS_VERSION_PREFIX, "#{url_origin(url)}\x00#{class_name}")
|
|
527
|
+
end
|
|
528
|
+
keys
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Store key for one version. Truncated SHA-256 of the application id
|
|
532
|
+
# and the material, so no path or class name appears in the key. In
|
|
533
|
+
# the keyspace layout it sits in the cache family, outside any tenant,
|
|
534
|
+
# so a write under one tenant retires every tenant's entries and a
|
|
535
|
+
# whole-keyspace clear still removes it.
|
|
536
|
+
# @!visibility private
|
|
537
|
+
def version_key(prefix, material)
|
|
538
|
+
digest = Digest::SHA256.hexdigest("#{@app_id}\x00#{material}")[0, 32]
|
|
539
|
+
return "#{@keyspace.family_prefix(:cache)}:#{prefix}:#{digest}" if @keyspace
|
|
540
|
+
key = "#{prefix}:#{digest}"
|
|
541
|
+
@namespace ? "#{@namespace}:#{key}" : key
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# @!visibility private
|
|
545
|
+
def url_origin(url)
|
|
546
|
+
"#{url.scheme}://#{url.host}:#{url.port}"
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# The class a path addresses and the object id, if any.
|
|
550
|
+
# @!visibility private
|
|
551
|
+
# @return [Array(String, String), nil] `[class_name, object_id_or_nil]`.
|
|
552
|
+
def class_scope(path)
|
|
553
|
+
path = path.to_s
|
|
554
|
+
if (m = path.match(CLASS_PATH_RE))
|
|
555
|
+
[m[1], m[2]]
|
|
556
|
+
elsif (m = path.match(SYSTEM_CLASS_PATH_RE))
|
|
557
|
+
[SYSTEM_CLASSES[m[1]], m[2]]
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# @!visibility private
|
|
562
|
+
# @return [Array<String>, nil] the current versions, or nil when any
|
|
563
|
+
# of them does not exist yet.
|
|
564
|
+
def read_versions(url)
|
|
565
|
+
versions = version_keys(url).map { |key| read_version(key) }
|
|
566
|
+
versions.include?(nil) ? nil : versions
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# The current versions, creating any that do not exist yet. Called
|
|
570
|
+
# before a read is dispatched, so the read is bound to versions that
|
|
571
|
+
# exist before the server answers it.
|
|
572
|
+
# @!visibility private
|
|
573
|
+
# @return [Array<String>]
|
|
574
|
+
def establish_versions(url)
|
|
575
|
+
version_keys(url).map { |key| establish_version(key) }
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# The current value of one version key, creating it when missing.
|
|
579
|
+
#
|
|
580
|
+
# Creation must not overwrite a version a concurrent write just
|
|
581
|
+
# bumped: the reader would then hold a version the write never
|
|
582
|
+
# retires. Where the store has an atomic set-if-absent (`create`),
|
|
583
|
+
# a lost race simply adopts the winner's value. Otherwise the value
|
|
584
|
+
# is written and read back, and whatever the store then holds is the
|
|
585
|
+
# version used. A write that landed in between still makes its
|
|
586
|
+
# post-response bump, which the in-flight check in {#call!} detects.
|
|
587
|
+
# @!visibility private
|
|
588
|
+
# @return [String]
|
|
589
|
+
def establish_version(key)
|
|
590
|
+
current = read_version(key)
|
|
591
|
+
return current if current
|
|
592
|
+
version = SecureRandom.hex(8)
|
|
593
|
+
if store_creates?
|
|
594
|
+
begin
|
|
595
|
+
return version if @store.create(key, version, expires: legacy_version_ttl)
|
|
596
|
+
current = read_version(key)
|
|
597
|
+
return current if current
|
|
598
|
+
rescue NotImplementedError
|
|
599
|
+
# Fall through to a plain write and read-back.
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
@store.store(key, version, expires: legacy_version_ttl)
|
|
603
|
+
read_version(key) || version
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# @!visibility private
|
|
607
|
+
# @return [String, nil]
|
|
608
|
+
def read_version(key)
|
|
609
|
+
value = @store[key]
|
|
610
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Whether the store offers an atomic set-if-absent.
|
|
614
|
+
# @!visibility private
|
|
615
|
+
def store_creates?
|
|
616
|
+
return false unless @store.respond_to?(:create)
|
|
617
|
+
return true unless @store.respond_to?(:supports?)
|
|
618
|
+
!!@store.supports?(:create)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Replace a version with a fresh random one. A random value rather
|
|
622
|
+
# than a counter: if the version key expires and is recreated, a
|
|
623
|
+
# counter could return to a value that older entries still carry and
|
|
624
|
+
# bring them back. A fresh nonce never matches an old entry.
|
|
625
|
+
# @!visibility private
|
|
626
|
+
# @return [String] the new version.
|
|
627
|
+
def write_version(key)
|
|
628
|
+
version = SecureRandom.hex(8)
|
|
629
|
+
@store.store(key, version, expires: legacy_version_ttl)
|
|
630
|
+
version
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Replace the resource version of every written path and the class
|
|
634
|
+
# version of every class those paths belong to.
|
|
635
|
+
# @!visibility private
|
|
636
|
+
def bump_versions(url, paths)
|
|
637
|
+
origin = url_origin(url)
|
|
638
|
+
keys = []
|
|
639
|
+
paths.each do |path|
|
|
640
|
+
keys << version_key(LEGACY_VERSION_PREFIX, "#{origin}#{path}")
|
|
641
|
+
class_name, = class_scope(path)
|
|
642
|
+
keys << version_key(CLASS_VERSION_PREFIX, "#{origin}\x00#{class_name}") if class_name
|
|
643
|
+
end
|
|
644
|
+
keys.uniq.each { |key| write_version(key) }
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Paths a write touches: the request path, plus every non-GET sub-request
|
|
648
|
+
# of a batch. A batch body that cannot be read contributes nothing
|
|
649
|
+
# beyond the batch path itself.
|
|
650
|
+
# @!visibility private
|
|
651
|
+
# @return [Array<String>]
|
|
652
|
+
def write_targets(env, url)
|
|
653
|
+
targets = [url.path]
|
|
654
|
+
return targets unless url.path.to_s.match?(BATCH_PATH_RE)
|
|
655
|
+
mount = url.path.to_s.sub(BATCH_PATH_RE, "")
|
|
656
|
+
batch_requests(env.body).each do |sub|
|
|
657
|
+
next unless sub.is_a?(Hash)
|
|
658
|
+
next if (sub["method"] || sub[:method]).to_s.casecmp?("GET")
|
|
659
|
+
path = (sub["path"] || sub[:path]).to_s
|
|
660
|
+
next if path.empty?
|
|
661
|
+
path = path.start_with?("/") ? path : "#{mount}/#{path}"
|
|
662
|
+
targets << path.split("?", 2).first
|
|
663
|
+
end
|
|
664
|
+
targets.uniq
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# @!visibility private
|
|
668
|
+
def batch_requests(body)
|
|
669
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
670
|
+
list = body.is_a?(Hash) ? (body["requests"] || body[:requests]) : nil
|
|
671
|
+
list.is_a?(Array) ? list : []
|
|
672
|
+
rescue JSON::ParserError
|
|
673
|
+
[]
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# The version key only needs to outlive the entries bound to it. When it
|
|
677
|
+
# expires first, those entries become unreachable (a miss), never stale.
|
|
678
|
+
# @!visibility private
|
|
679
|
+
def legacy_version_ttl
|
|
680
|
+
[@expires.to_i * 2, 60].max
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# The live cache key for a read bound to `versions`. Both layouts bind
|
|
684
|
+
# the key to the credential that produced the body (`@legacy_auth`: a
|
|
685
|
+
# digest of the application id with the session token, master key or
|
|
686
|
+
# REST key actually sent), so a wrong master key or another REST key
|
|
687
|
+
# cannot read an entry cached under the right one.
|
|
688
|
+
#
|
|
689
|
+
# In the keyspace layout the credential digest and the versions are
|
|
690
|
+
# folded into the auth segment, after the URL digest, so
|
|
691
|
+
# {Parse::Cache::Keyspace#resource_pattern} still matches every
|
|
692
|
+
# credential and version variant of a resource.
|
|
693
|
+
# @!visibility private
|
|
694
|
+
def versioned_key(url, versions)
|
|
695
|
+
tag = versions.join(".")
|
|
696
|
+
if @keyspace
|
|
697
|
+
auth = Digest::SHA256.hexdigest("#{@legacy_auth}\x00#{tag}")[0, 32]
|
|
698
|
+
@keyspace.cache_key(url, auth: auth, tenant: @cache_tenant)
|
|
699
|
+
else
|
|
700
|
+
"#{@legacy_cache_key}#v=#{tag}"
|
|
701
|
+
end
|
|
364
702
|
end
|
|
365
703
|
|
|
366
704
|
# Whether to also evict the pre-keyspace key shape. Always true before a
|
|
@@ -383,8 +721,13 @@ module Parse
|
|
|
383
721
|
if @store.respond_to?(:delete_matching)
|
|
384
722
|
@store.delete_matching(pattern)
|
|
385
723
|
else
|
|
724
|
+
# Pre-credential key shapes, from before entries were bound to the
|
|
725
|
+
# credential and versions. A store that cannot match a pattern has
|
|
726
|
+
# no way to name the current variants of other callers; the
|
|
727
|
+
# version bump in {#delete_cache_variants} retires those instead.
|
|
386
728
|
@store.delete @keyspace.cache_key(url, auth: :anon, tenant: @cache_tenant)
|
|
387
729
|
@store.delete @keyspace.cache_key(url, auth: :master, tenant: @cache_tenant)
|
|
730
|
+
@store.delete @keyspace.cache_key(url, auth: @cache_auth, tenant: @cache_tenant)
|
|
388
731
|
end
|
|
389
732
|
end
|
|
390
733
|
|
|
@@ -401,7 +744,8 @@ module Parse
|
|
|
401
744
|
@store.delete url.to_s # regular
|
|
402
745
|
@store.delete "mk:#{url.to_s}" # master key cache-key
|
|
403
746
|
end
|
|
404
|
-
|
|
747
|
+
# The caller's own entry in the pre-credential shape.
|
|
748
|
+
@store.delete @old_shape_key if @old_shape_key
|
|
405
749
|
end
|
|
406
750
|
end #Caching
|
|
407
751
|
end #Middleware
|
data/lib/parse/client/request.rb
CHANGED
|
@@ -109,8 +109,12 @@ module Parse
|
|
|
109
109
|
self.method = method
|
|
110
110
|
self.path = uri
|
|
111
111
|
self.body = body
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
# Copy the caller's hashes: the request id header is written into
|
|
113
|
+
# `headers` below, and writing it into a hash the caller reuses would
|
|
114
|
+
# send the same id on the caller's next request, which Parse Server's
|
|
115
|
+
# idempotency layer rejects as a duplicate.
|
|
116
|
+
self.headers = headers ? headers.dup : {}
|
|
117
|
+
self.opts = opts ? opts.dup : {}
|
|
114
118
|
|
|
115
119
|
# Handle request ID for idempotency
|
|
116
120
|
setup_request_id
|
|
@@ -199,24 +203,32 @@ module Parse
|
|
|
199
203
|
true
|
|
200
204
|
end
|
|
201
205
|
|
|
206
|
+
# Endpoints that never get an automatic request id. Matched against the
|
|
207
|
+
# path with any leading slash and mount prefix allowed, since requests
|
|
208
|
+
# carry both relative (`functions/foo`) and absolute
|
|
209
|
+
# (`/parse/functions/foo`) paths.
|
|
210
|
+
NON_IDEMPOTENT_PATH_PATTERNS = [
|
|
211
|
+
%r{(?:\A|/)sessions(?:/|\z)}, # Session creation/management
|
|
212
|
+
%r{(?:\A|/)logout(?:/|\z)}, # Logout operations
|
|
213
|
+
%r{(?:\A|/)requestPasswordReset(?:/|\z)}, # Password reset requests
|
|
214
|
+
%r{(?:\A|/)functions/}, # Cloud functions (may have their own logic)
|
|
215
|
+
%r{(?:\A|/)jobs/}, # Background jobs
|
|
216
|
+
%r{(?:\A|/)events/}, # Analytics events
|
|
217
|
+
%r{(?:\A|/)push(?:/|\z)}, # Push notifications
|
|
218
|
+
].freeze
|
|
219
|
+
|
|
202
220
|
# Checks if the request path should not use request IDs
|
|
203
221
|
# @return [Boolean]
|
|
204
222
|
def non_idempotent_path?
|
|
205
223
|
# GET requests are naturally idempotent
|
|
206
224
|
return true if @method == :get
|
|
207
225
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
%r{/jobs/}, # Background jobs
|
|
215
|
-
%r{/events/}, # Analytics events
|
|
216
|
-
%r{/push}, # Push notifications
|
|
217
|
-
]
|
|
218
|
-
|
|
219
|
-
non_idempotent_patterns.any? { |pattern| @path =~ pattern }
|
|
226
|
+
path = @path.to_s.split("?", 2).first
|
|
227
|
+
# Object paths are never one of the special endpoints, even when the
|
|
228
|
+
# class is named like one (`classes/push`).
|
|
229
|
+
return false if path.match?(%r{(?:\A|/)classes/})
|
|
230
|
+
|
|
231
|
+
NON_IDEMPOTENT_PATH_PATTERNS.any? { |pattern| path.match?(pattern) }
|
|
220
232
|
end
|
|
221
233
|
|
|
222
234
|
# Generates a unique request ID
|