parse-stack-next 4.5.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 +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/inflector"
|
|
6
|
+
require "active_support/core_ext"
|
|
7
|
+
# Note: Do not require "../object" here - this file is loaded from object.rb
|
|
8
|
+
# and adding that require would create a circular dependency.
|
|
9
|
+
|
|
10
|
+
module Parse
|
|
11
|
+
# Create all Parse::Object subclasses, including their properties and inferred
|
|
12
|
+
# associations by importing the schema for the remote collections in a Parse
|
|
13
|
+
# application. Uses the default configured client.
|
|
14
|
+
# @return [Array] an array of created Parse::Object subclasses.
|
|
15
|
+
# @see Parse::Model::Builder.build!
|
|
16
|
+
def self.auto_generate_models!
|
|
17
|
+
Parse.schemas.map do |schema|
|
|
18
|
+
Parse::Model::Builder.build!(schema)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Namespace where +Parse.auto_generate_models!+ installs dynamically
|
|
23
|
+
# generated +Parse::Object+ subclasses derived from server-side schema.
|
|
24
|
+
# Isolating them here prevents server-returned className strings from
|
|
25
|
+
# rebinding top-level constants like ::File, ::Logger, ::Process.
|
|
26
|
+
module Generated
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class Model
|
|
30
|
+
# This class provides a method to automatically generate Parse::Object subclasses, including
|
|
31
|
+
# their properties and inferred associations by importing the schema for the remote collections
|
|
32
|
+
# in a Parse application.
|
|
33
|
+
class Builder
|
|
34
|
+
# Regex matching className strings safe to install as a Ruby constant.
|
|
35
|
+
# Server-returned className must satisfy this; otherwise we refuse to
|
|
36
|
+
# touch the global namespace.
|
|
37
|
+
VALID_CLASS_NAME = /\A_?[A-Za-z][A-Za-z0-9_]{0,127}\z/.freeze
|
|
38
|
+
|
|
39
|
+
# Parse Server system classes that ship with the SDK as hand-written
|
|
40
|
+
# subclasses (Parse::User, Parse::Role, etc.). Schema-driven builds
|
|
41
|
+
# must NOT install additional fields or associations on these — a
|
|
42
|
+
# compromised Parse Server could otherwise inject an +is_admin+
|
|
43
|
+
# property onto the real +Parse::User+ class, or a +password_history+
|
|
44
|
+
# accessor onto +_Session+, by returning a poisoned schema.
|
|
45
|
+
PROTECTED_SYSTEM_CLASSES = %w[
|
|
46
|
+
_User _Role _Session _Installation _Product _Audience _PushStatus
|
|
47
|
+
_JobStatus _JobSchedule _Hooks _GlobalConfig _SCHEMA _GraphQLConfig
|
|
48
|
+
_Idempotency _Audit
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
# Builds a ruby Parse::Object subclass with the provided schema information.
|
|
52
|
+
# @param schema [Hash] the Parse-formatted hash schema for a collection. This hash
|
|
53
|
+
# should two keys:
|
|
54
|
+
# * className: Contains the name of the collection.
|
|
55
|
+
# * field: A hash containg the column fields and their type.
|
|
56
|
+
# @raise ArgumentError when the className could not be inferred from the schema.
|
|
57
|
+
# @return [Array] an array of Parse::Object subclass constants.
|
|
58
|
+
def self.build!(schema)
|
|
59
|
+
unless schema.is_a?(Hash)
|
|
60
|
+
raise ArgumentError, "Schema parameter should be a Parse schema hash object."
|
|
61
|
+
end
|
|
62
|
+
schema = schema.with_indifferent_access
|
|
63
|
+
fields = schema[:fields] || {}
|
|
64
|
+
className = schema[:className]
|
|
65
|
+
|
|
66
|
+
if className.blank?
|
|
67
|
+
raise ArgumentError, "No valid className provided for schema hash"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Strictly validate the server-returned className before any constant
|
|
71
|
+
# resolution. This blocks schema-poisoning attacks where a malicious
|
|
72
|
+
# or compromised Parse Server returns a className like "File",
|
|
73
|
+
# "Kernel", or "../foo" intending to either rebind a Ruby built-in
|
|
74
|
+
# constant via const_set or trigger arbitrary autoload via const_get.
|
|
75
|
+
parse_class_name = className.to_parse_class
|
|
76
|
+
unless parse_class_name.is_a?(String) && parse_class_name =~ VALID_CLASS_NAME
|
|
77
|
+
raise ArgumentError, "Unsafe className from schema: #{className.inspect}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Prefer the registered Parse::Object descendant lookup (never touches
|
|
81
|
+
# top-level constants). Only fall back to constant lookup within the
|
|
82
|
+
# Parse::Generated namespace, never on ::Object.
|
|
83
|
+
klass = Parse::Model.find_class(className)
|
|
84
|
+
if klass.nil?
|
|
85
|
+
if Parse::Generated.const_defined?(parse_class_name, false)
|
|
86
|
+
klass = Parse::Generated.const_get(parse_class_name, false)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
if klass.nil?
|
|
90
|
+
klass = ::Class.new(Parse::Object)
|
|
91
|
+
Parse::Generated.const_set(parse_class_name, klass)
|
|
92
|
+
end
|
|
93
|
+
unless klass.is_a?(Class) && klass <= Parse::Object
|
|
94
|
+
raise ArgumentError, "Resolved class #{klass.inspect} for #{className.inspect} is not a Parse::Object subclass"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Refuse to install schema-derived fields on protected system
|
|
98
|
+
# classes. The class is still returned (so callers that call
|
|
99
|
+
# build! purely for the class lookup continue to work) but no
|
|
100
|
+
# attacker-controlled belongs_to/has_many/property is added.
|
|
101
|
+
if PROTECTED_SYSTEM_CLASSES.include?(className.to_s)
|
|
102
|
+
return klass
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
base_fields = Parse::Properties::BASE.keys
|
|
106
|
+
class_fields = klass.field_map.values + [:className]
|
|
107
|
+
fields.each do |field, type|
|
|
108
|
+
field = field.to_sym
|
|
109
|
+
key = field.to_s.underscore.to_sym
|
|
110
|
+
next if base_fields.include?(field) || class_fields.include?(field)
|
|
111
|
+
|
|
112
|
+
data_type = type[:type].downcase.to_sym
|
|
113
|
+
if data_type == :pointer
|
|
114
|
+
klass.belongs_to key, as: safe_target_class(type[:targetClass]), field: field
|
|
115
|
+
elsif data_type == :relation
|
|
116
|
+
klass.has_many key, through: :relation, as: safe_target_class(type[:targetClass]), field: field
|
|
117
|
+
else
|
|
118
|
+
klass.property key, data_type, field: field
|
|
119
|
+
end
|
|
120
|
+
class_fields.push(field)
|
|
121
|
+
end
|
|
122
|
+
klass
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @!visibility private
|
|
126
|
+
# Validates a server-returned +targetClass+ string before forwarding
|
|
127
|
+
# it to +belongs_to+/+has_many+. Returns +nil+ for missing or
|
|
128
|
+
# invalid values so the association DSL falls back to its inferred
|
|
129
|
+
# default rather than installing an attacker-controlled class name
|
|
130
|
+
# (which could pivot a later type-confusion bypass).
|
|
131
|
+
def self.safe_target_class(target)
|
|
132
|
+
return nil if target.nil? || target.to_s.empty?
|
|
133
|
+
s = target.to_s
|
|
134
|
+
return nil unless s =~ VALID_CLASS_NAME
|
|
135
|
+
s
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "json"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "monitor"
|
|
9
|
+
|
|
10
|
+
module Parse
|
|
11
|
+
# Mutual-exclusion primitive for `first_or_create!` / `create_or_update!` to
|
|
12
|
+
# prevent TOCTOU duplicate creation under concurrency. Backed by a Moneta
|
|
13
|
+
# cache store (typically Redis) using atomic `#create` (SETNX semantics).
|
|
14
|
+
#
|
|
15
|
+
# This is the *latency optimization* layer; a MongoDB unique index on the
|
|
16
|
+
# constrained tuple is the correctness floor that survives Redis outages,
|
|
17
|
+
# TTL expiry races, and bypassed locks. The lock here is best-effort and
|
|
18
|
+
# fails open by design when the store is unreachable.
|
|
19
|
+
#
|
|
20
|
+
# Threading model:
|
|
21
|
+
# - Process-local fallback: when the store is the in-memory Moneta adapter
|
|
22
|
+
# (or unconfigured), the lock degrades to a per-key `Mutex`. Threads in
|
|
23
|
+
# the same Ruby process serialize; cross-process callers do not. This is
|
|
24
|
+
# safe for single-Puma-process tests but does not protect production
|
|
25
|
+
# deployments with multiple dynos / workers.
|
|
26
|
+
# - Cross-process: when the store is Redis-backed Moneta, `#create` is
|
|
27
|
+
# atomic and the lock excludes other processes.
|
|
28
|
+
#
|
|
29
|
+
# Key derivation: HMAC-SHA256 of a canonical payload when a secret is
|
|
30
|
+
# configured (preferred); plain SHA256 otherwise (deterministic across
|
|
31
|
+
# processes — required for Redis-backed locking — but key material is
|
|
32
|
+
# enumerable via Redis MONITOR/snapshots). Operators wanting hardened key
|
|
33
|
+
# material against snapshot/MONITOR exposure should set
|
|
34
|
+
# `PARSE_STACK_LOCK_SECRET` or `Parse.synchronize_create_secret`.
|
|
35
|
+
module CreateLock
|
|
36
|
+
DEFAULT_TTL = 3
|
|
37
|
+
DEFAULT_WAIT = 2.0
|
|
38
|
+
DEFAULT_POLL_BASE = 0.05
|
|
39
|
+
DEFAULT_POLL_JITTER = 0.015
|
|
40
|
+
MAX_TTL = 30
|
|
41
|
+
MAX_WAIT = 30
|
|
42
|
+
MAX_PAYLOAD_BYTES = 8_192
|
|
43
|
+
MAX_DEPTH = 4
|
|
44
|
+
KEY_PREFIX = "parse-stack:foc:v1:"
|
|
45
|
+
DEGRADED_WARNING_THROTTLE_SECONDS = 60
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# Run `block` while holding a mutex keyed by the canonical form of
|
|
49
|
+
# `parse_class + auth context + query_attrs`. Yields nothing; the
|
|
50
|
+
# block's return value is returned.
|
|
51
|
+
#
|
|
52
|
+
# @param parse_class [String] the Parse class name
|
|
53
|
+
# @param query_attrs [Hash] the query attributes used to derive the key
|
|
54
|
+
# @param options [Hash] tuning: ttl:, wait:, on_degraded:
|
|
55
|
+
# @param session_token [String, nil] auth context — included in key
|
|
56
|
+
# @param master_key [Boolean, nil] auth context — included in key
|
|
57
|
+
# @raise [Parse::CreateLockInvalidKey] if query_attrs cannot be canonicalized
|
|
58
|
+
# @raise [Parse::CreateLockTimeoutError] if wait budget exceeded
|
|
59
|
+
def synchronize(parse_class:, query_attrs:, options: {}, session_token: nil, master_key: nil, &block)
|
|
60
|
+
raise ArgumentError, "block required" unless block_given?
|
|
61
|
+
|
|
62
|
+
ttl = clamp(Integer(options[:ttl] || DEFAULT_TTL), 1, MAX_TTL)
|
|
63
|
+
wait = clamp(Float(options[:wait] || DEFAULT_WAIT), 0.0, MAX_WAIT)
|
|
64
|
+
on_degraded = options[:on_degraded] || :warn
|
|
65
|
+
|
|
66
|
+
key = canonical_key(
|
|
67
|
+
parse_class: parse_class,
|
|
68
|
+
query_attrs: query_attrs,
|
|
69
|
+
session_token: session_token,
|
|
70
|
+
master_key: master_key,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
store = lock_store
|
|
74
|
+
if degraded_store?(store)
|
|
75
|
+
handle_degraded(on_degraded, key)
|
|
76
|
+
return process_mutex(key).synchronize(&block)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
owner = SecureRandom.uuid
|
|
80
|
+
acquired_at = nil
|
|
81
|
+
start = monotonic_now
|
|
82
|
+
|
|
83
|
+
loop do
|
|
84
|
+
if try_acquire(store, key, owner, ttl)
|
|
85
|
+
acquired_at = monotonic_now
|
|
86
|
+
wait_ms = ((acquired_at - start) * 1000).round
|
|
87
|
+
instrument("acquired", key, wait_ms: wait_ms)
|
|
88
|
+
break
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
elapsed = monotonic_now - start
|
|
92
|
+
if elapsed >= wait
|
|
93
|
+
waited_ms = (elapsed * 1000).round
|
|
94
|
+
instrument("timeout", key, waited_ms: waited_ms)
|
|
95
|
+
raise Parse::CreateLockTimeoutError,
|
|
96
|
+
"Could not acquire create-lock for #{parse_class} within #{wait}s"
|
|
97
|
+
end
|
|
98
|
+
instrument("contended", key, elapsed_ms: (elapsed * 1000).round) if elapsed > 0
|
|
99
|
+
sleep(poll_interval)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
yield
|
|
104
|
+
ensure
|
|
105
|
+
if acquired_at
|
|
106
|
+
release(store, key, owner)
|
|
107
|
+
held_ms = ((monotonic_now - acquired_at) * 1000).round
|
|
108
|
+
instrument("released", key, held_ms: held_ms)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Canonical lock key for the given inputs. Public for tests.
|
|
114
|
+
# @return [String]
|
|
115
|
+
def canonical_key(parse_class:, query_attrs:, session_token: nil, master_key: nil)
|
|
116
|
+
principal = principal_marker(session_token, master_key)
|
|
117
|
+
attrs_payload = canonicalize_attrs(query_attrs, parse_class: parse_class)
|
|
118
|
+
app_id = parse_application_id
|
|
119
|
+
payload = "#{app_id}|#{parse_class}|#{principal}|#{attrs_payload}"
|
|
120
|
+
|
|
121
|
+
if payload.bytesize > MAX_PAYLOAD_BYTES
|
|
122
|
+
raise Parse::CreateLockInvalidKey,
|
|
123
|
+
"synchronize key payload exceeds #{MAX_PAYLOAD_BYTES} bytes (got #{payload.bytesize})"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
secret = lock_secret_for(store: lock_store)
|
|
127
|
+
digest = if secret
|
|
128
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
|
|
129
|
+
else
|
|
130
|
+
Digest::SHA256.hexdigest(payload)
|
|
131
|
+
end
|
|
132
|
+
"#{KEY_PREFIX}#{digest}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @!visibility private
|
|
136
|
+
def reset!
|
|
137
|
+
@auto_secret = nil
|
|
138
|
+
@plain_sha_warned = nil
|
|
139
|
+
@degraded_warned_at = nil
|
|
140
|
+
@process_mutex_registry = nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def lock_store
|
|
146
|
+
if Parse.respond_to?(:synchronize_create_store) && Parse.synchronize_create_store
|
|
147
|
+
return Parse.synchronize_create_store
|
|
148
|
+
end
|
|
149
|
+
Parse.cache
|
|
150
|
+
rescue Parse::Error::ConnectionError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_application_id
|
|
155
|
+
Parse.client.application_id
|
|
156
|
+
rescue Parse::Error::ConnectionError
|
|
157
|
+
"no-app-id"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def principal_marker(session_token, master_key)
|
|
161
|
+
return "default" if session_token.nil? && master_key.nil?
|
|
162
|
+
if session_token
|
|
163
|
+
"st:#{Digest::SHA256.hexdigest(session_token.to_s)[0, 16]}"
|
|
164
|
+
elsif master_key == true
|
|
165
|
+
"mk"
|
|
166
|
+
elsif master_key == false
|
|
167
|
+
"no-mk"
|
|
168
|
+
else
|
|
169
|
+
"default"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def canonicalize_attrs(query_attrs, parse_class: nil)
|
|
174
|
+
raise Parse::CreateLockInvalidKey, "synchronize requires non-empty query_attrs" if query_attrs.nil? || query_attrs.empty?
|
|
175
|
+
unless query_attrs.is_a?(Hash)
|
|
176
|
+
raise Parse::CreateLockInvalidKey, "synchronize query_attrs must be a Hash (got #{query_attrs.class})"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
seen = {}
|
|
180
|
+
pairs = query_attrs.map do |k, v|
|
|
181
|
+
key_str = canonicalize_key_name(k)
|
|
182
|
+
if seen.key?(key_str)
|
|
183
|
+
class_ctx = parse_class ? " on #{parse_class}" : ""
|
|
184
|
+
raise Parse::CreateLockInvalidKey,
|
|
185
|
+
"duplicate canonical key #{key_str.inspect}#{class_ctx} in synchronize query_attrs " \
|
|
186
|
+
"(Parse::Operation has no eql?/hash override, so two instances with the " \
|
|
187
|
+
"same operand+operator are distinct Hash keys but collapse here)"
|
|
188
|
+
end
|
|
189
|
+
seen[key_str] = true
|
|
190
|
+
[key_str, canonicalize_value(v, 0)]
|
|
191
|
+
end
|
|
192
|
+
sorted = pairs.sort_by(&:first).to_h
|
|
193
|
+
JSON.generate(sorted)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def canonicalize_key_name(key)
|
|
197
|
+
# Parse::Operation keys (e.g. :project.exists, :email.gt) encode as
|
|
198
|
+
# "<operand>\u0000op_<operator>" so the lock keys the filter shape, not
|
|
199
|
+
# just equality tuples. The null-byte separator is a structural marker
|
|
200
|
+
# that must never appear in plain string keys (rejected below) or in
|
|
201
|
+
# operand/operator names.
|
|
202
|
+
if key.is_a?(Parse::Operation) || (key.respond_to?(:operator) && key.respond_to?(:operand))
|
|
203
|
+
operand = key.operand.to_s
|
|
204
|
+
operator = key.operator.to_s
|
|
205
|
+
if operand.empty? || operator.empty? ||
|
|
206
|
+
operand.include?(".") || operator.include?(".") ||
|
|
207
|
+
operand.start_with?("$") || operator.start_with?("$") ||
|
|
208
|
+
operand.include?("\u0000") || operator.include?("\u0000")
|
|
209
|
+
raise Parse::CreateLockInvalidKey,
|
|
210
|
+
"invalid Parse::Operation key in synchronize (got #{key.inspect})"
|
|
211
|
+
end
|
|
212
|
+
return "#{operand}\u0000op_#{operator}"
|
|
213
|
+
end
|
|
214
|
+
str = key.to_s
|
|
215
|
+
if str.include?(".") || str.include?("\u0000")
|
|
216
|
+
raise Parse::CreateLockInvalidKey,
|
|
217
|
+
"dotted or null-byte keys not allowed in synchronize (got #{key.inspect})"
|
|
218
|
+
end
|
|
219
|
+
str
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def canonicalize_value(value, depth)
|
|
223
|
+
if depth > MAX_DEPTH
|
|
224
|
+
raise Parse::CreateLockInvalidKey, "synchronize values nested deeper than #{MAX_DEPTH} levels"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
case value
|
|
228
|
+
when nil, true, false, Integer, Float, String, Symbol
|
|
229
|
+
value.is_a?(Symbol) ? value.to_s : value
|
|
230
|
+
when Time, DateTime
|
|
231
|
+
value.utc.iso8601(6)
|
|
232
|
+
when Date
|
|
233
|
+
value.iso8601
|
|
234
|
+
when Array
|
|
235
|
+
value.map { |v| canonicalize_value(v, depth + 1) }
|
|
236
|
+
when Parse::Pointer
|
|
237
|
+
if value.id.nil? || value.id.empty?
|
|
238
|
+
raise Parse::CreateLockInvalidKey,
|
|
239
|
+
"unsaved Parse pointer cannot be a synchronize key component (#{value.parse_class}#nil)"
|
|
240
|
+
end
|
|
241
|
+
"ptr:#{value.parse_class}:#{value.id}"
|
|
242
|
+
when Hash
|
|
243
|
+
raise Parse::CreateLockInvalidKey,
|
|
244
|
+
"nested Hash values not allowed in synchronize (would be ambiguous against query operators)"
|
|
245
|
+
when Proc, Method, Regexp
|
|
246
|
+
raise Parse::CreateLockInvalidKey, "#{value.class} not allowed in synchronize values"
|
|
247
|
+
else
|
|
248
|
+
if value.respond_to?(:to_pointer)
|
|
249
|
+
ptr = value.to_pointer
|
|
250
|
+
return canonicalize_value(ptr, depth + 1)
|
|
251
|
+
end
|
|
252
|
+
raise Parse::CreateLockInvalidKey, "unsupported type #{value.class} in synchronize values"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def degraded_store?(store)
|
|
257
|
+
return true if store.nil?
|
|
258
|
+
bottom = walk_to_adapter(store)
|
|
259
|
+
return true if bottom.nil?
|
|
260
|
+
klass_name = bottom.class.name.to_s
|
|
261
|
+
klass_name.include?("Memory") || klass_name.include?("Null")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def walk_to_adapter(store)
|
|
265
|
+
current = store
|
|
266
|
+
# Walk transformer chain to find bottom Moneta adapter
|
|
267
|
+
while current.respond_to?(:adapter) && current.adapter && current.adapter != current
|
|
268
|
+
current = current.adapter
|
|
269
|
+
end
|
|
270
|
+
current
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def handle_degraded(mode, key)
|
|
274
|
+
case mode
|
|
275
|
+
when :raise
|
|
276
|
+
raise Parse::CreateLockUnavailableError,
|
|
277
|
+
"synchronize requires a cross-process cache store (Redis); current store is process-local"
|
|
278
|
+
when :proceed
|
|
279
|
+
# silent
|
|
280
|
+
when :warn_throttled
|
|
281
|
+
now = monotonic_now
|
|
282
|
+
if @degraded_warned_at.nil? || (now - @degraded_warned_at) >= DEGRADED_WARNING_THROTTLE_SECONDS
|
|
283
|
+
@degraded_warned_at = now
|
|
284
|
+
warn "[Parse::CreateLock] Lock store is process-local (Moneta Memory/Null). " \
|
|
285
|
+
"Cross-process locking is NOT in effect. Configure a Redis-backed cache to enable distributed locking."
|
|
286
|
+
end
|
|
287
|
+
else # :warn (default)
|
|
288
|
+
warn "[Parse::CreateLock] Lock store is process-local; cross-process locking disabled. " \
|
|
289
|
+
"key_digest=#{key[KEY_PREFIX.size, 12]}…"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def try_acquire(store, key, owner, ttl)
|
|
294
|
+
# Trigger lazy TTL sweep on Moneta::Memory before #create (no-op on Redis).
|
|
295
|
+
# Without this, Memory adapter returns false on #create even after TTL expiry
|
|
296
|
+
# until a #key? or #[] access flushes the stale entry.
|
|
297
|
+
store.key?(key)
|
|
298
|
+
store.create(key, owner, expires: ttl)
|
|
299
|
+
rescue StandardError => e
|
|
300
|
+
warn "[Parse::CreateLock] acquire error (#{e.class}): #{e.message}"
|
|
301
|
+
false
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def release(store, key, owner)
|
|
305
|
+
# Best-effort compare-and-delete. Moneta does not expose atomic CAD;
|
|
306
|
+
# the worst-case race here is bounded by the short TTL (default #{DEFAULT_TTL}s).
|
|
307
|
+
current = store[key]
|
|
308
|
+
store.delete(key) if current == owner
|
|
309
|
+
rescue StandardError => e
|
|
310
|
+
warn "[Parse::CreateLock] release error (#{e.class}): #{e.message}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def poll_interval
|
|
314
|
+
DEFAULT_POLL_BASE + (rand * 2 - 1) * DEFAULT_POLL_JITTER
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def monotonic_now
|
|
318
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def clamp(value, lo, hi)
|
|
322
|
+
[lo, value, hi].sort[1]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def process_mutex(key)
|
|
326
|
+
@process_mutex_registry_lock ||= Mutex.new
|
|
327
|
+
@process_mutex_registry_lock.synchronize do
|
|
328
|
+
@process_mutex_registry ||= {}
|
|
329
|
+
@process_mutex_registry[key] ||= Mutex.new
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def lock_secret_for(store:)
|
|
334
|
+
configured = configured_secret
|
|
335
|
+
return configured if configured && !configured.empty?
|
|
336
|
+
|
|
337
|
+
# No operator-configured secret. Behavior depends on store type:
|
|
338
|
+
# - Memory/Null adapter: locking is already process-local, so a
|
|
339
|
+
# per-process auto-derived HMAC secret is fine and preserves
|
|
340
|
+
# privacy in tests / single-process deployments.
|
|
341
|
+
# - Redis (or any cross-process store): a per-process secret would
|
|
342
|
+
# break cross-process key equality, defeating the lock. Fall back
|
|
343
|
+
# to plain SHA256 with a one-time warning so operators know to
|
|
344
|
+
# harden key material.
|
|
345
|
+
if degraded_store?(store)
|
|
346
|
+
auto_secret
|
|
347
|
+
else
|
|
348
|
+
warn_plain_sha_once
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def configured_secret
|
|
354
|
+
if Parse.respond_to?(:synchronize_create_secret) && Parse.synchronize_create_secret
|
|
355
|
+
return Parse.synchronize_create_secret.to_s
|
|
356
|
+
end
|
|
357
|
+
ENV["PARSE_STACK_LOCK_SECRET"]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def auto_secret
|
|
361
|
+
@auto_secret ||= SecureRandom.hex(32)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def warn_plain_sha_once
|
|
365
|
+
return if @plain_sha_warned
|
|
366
|
+
@plain_sha_warned = true
|
|
367
|
+
warn "[Parse::CreateLock:SECURITY] No PARSE_STACK_LOCK_SECRET configured and Redis-backed store detected. " \
|
|
368
|
+
"Falling back to plain SHA256 for lock-key derivation so cross-process locking actually works. " \
|
|
369
|
+
"Lock keys are deterministic and may expose query_attrs content via Redis MONITOR/snapshots. " \
|
|
370
|
+
"Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') to enable HMAC keying."
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def instrument(event, key, payload = {})
|
|
374
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
375
|
+
ActiveSupport::Notifications.instrument(
|
|
376
|
+
"parse.synchronize_create.#{event}",
|
|
377
|
+
{ key_digest: key[KEY_PREFIX.size, 12] }.merge(payload),
|
|
378
|
+
)
|
|
379
|
+
rescue StandardError
|
|
380
|
+
# never let telemetry break the lock
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
|