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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. 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
+