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,1001 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "uri"
6
+ require "socket"
7
+ require "openssl"
8
+ require "securerandom"
9
+ require "base64"
10
+ require "digest"
11
+ require "monitor"
12
+ require "timeout"
13
+
14
+ require_relative "health_monitor"
15
+ require_relative "circuit_breaker"
16
+ require_relative "event_queue"
17
+ require_relative "../pipeline_security"
18
+
19
+ module Parse
20
+ module LiveQuery
21
+ # WebSocket client for Parse LiveQuery server.
22
+ # Manages WebSocket connection, authentication, and subscription lifecycle.
23
+ #
24
+ # Features:
25
+ # - Automatic ping/pong keep-alive with stale connection detection
26
+ # - Circuit breaker for intelligent failure handling
27
+ # - Event queue with backpressure protection
28
+ # - Automatic reconnection with exponential backoff and jitter
29
+ #
30
+ # @example Basic usage
31
+ # client = Parse::LiveQuery::Client.new(
32
+ # url: "wss://your-parse-server.com",
33
+ # application_id: "your_app_id",
34
+ # client_key: "your_client_key"
35
+ # )
36
+ #
37
+ # subscription = client.subscribe("Song", where: { artist: "Beatles" })
38
+ # subscription.on(:create) { |song| puts "New song!" }
39
+ #
40
+ # client.shutdown(timeout: 5)
41
+ #
42
+ class Client
43
+ # WebSocket operation codes
44
+ OPCODE_CONTINUATION = 0x0
45
+ OPCODE_TEXT = 0x1
46
+ OPCODE_BINARY = 0x2
47
+ OPCODE_CLOSE = 0x8
48
+ OPCODE_PING = 0x9
49
+ OPCODE_PONG = 0xA
50
+
51
+ # Default maximum message size (1MB) - prevents memory exhaustion attacks
52
+ DEFAULT_MAX_MESSAGE_SIZE = 1_048_576
53
+
54
+ # Default frame read timeout in seconds - prevents indefinite blocking
55
+ DEFAULT_FRAME_READ_TIMEOUT = 30
56
+
57
+ # @return [String] WebSocket URL
58
+ attr_reader :url
59
+
60
+ # @return [String] Parse application ID
61
+ attr_reader :application_id
62
+
63
+ # @return [String, nil] Parse client key (REST API key)
64
+ attr_reader :client_key
65
+
66
+ # @return [String, nil] Parse master key
67
+ attr_reader :master_key
68
+
69
+ # @return [Symbol] connection state (:disconnected, :connecting, :connected, :closed)
70
+ attr_reader :state
71
+
72
+ # @return [Hash<Integer, Subscription>] active subscriptions by request ID
73
+ attr_reader :subscriptions
74
+
75
+ # @return [HealthMonitor] connection health monitor
76
+ attr_reader :health_monitor
77
+
78
+ # @return [CircuitBreaker] connection circuit breaker
79
+ attr_reader :circuit_breaker
80
+
81
+ # @return [EventQueue] event processing queue
82
+ attr_reader :event_queue
83
+
84
+ # @return [Integer] maximum allowed message size in bytes
85
+ attr_reader :max_message_size
86
+
87
+ # @return [Integer] frame read timeout in seconds
88
+ attr_reader :frame_read_timeout
89
+
90
+ # Create a new LiveQuery client
91
+ # @param url [String] WebSocket URL (wss://...)
92
+ # @param application_id [String] Parse application ID
93
+ # @param client_key [String] Parse REST API key
94
+ # @param master_key [String, nil] Parse master key (optional)
95
+ # @param auto_connect [Boolean] connect immediately (default: true)
96
+ # @param auto_reconnect [Boolean] automatically reconnect on disconnect (default: true)
97
+ def initialize(url: nil, application_id: nil, client_key: nil, master_key: nil,
98
+ auto_connect: nil, auto_reconnect: nil)
99
+ cfg = config
100
+
101
+ # Use provided values or fall back to configuration/environment
102
+ @url = url || cfg.url || derive_websocket_url
103
+ @application_id = application_id || cfg.application_id ||
104
+ parse_client_value(:application_id)
105
+ @client_key = client_key || cfg.client_key ||
106
+ parse_client_value(:api_key)
107
+ @master_key = master_key || cfg.master_key ||
108
+ parse_client_value(:master_key)
109
+
110
+ @auto_connect = auto_connect.nil? ? cfg.auto_connect : auto_connect
111
+ @auto_reconnect = auto_reconnect.nil? ? cfg.auto_reconnect : auto_reconnect
112
+ @max_message_size = cfg.max_message_size || DEFAULT_MAX_MESSAGE_SIZE
113
+ @frame_read_timeout = cfg.frame_read_timeout || DEFAULT_FRAME_READ_TIMEOUT
114
+
115
+ @state = :disconnected
116
+ @subscriptions = {}
117
+ @monitor = Monitor.new
118
+ @socket = nil
119
+ @reader_thread = nil
120
+ @reconnect_thread = nil
121
+ @reconnect_interval = cfg.initial_reconnect_interval
122
+ @callbacks = Hash.new { |h, k| h[k] = [] }
123
+ @client_id = nil
124
+
125
+ # Initialize production components
126
+ @health_monitor = HealthMonitor.new(
127
+ client: self,
128
+ ping_interval: cfg.ping_interval,
129
+ pong_timeout: cfg.pong_timeout,
130
+ )
131
+
132
+ @circuit_breaker = CircuitBreaker.new(
133
+ failure_threshold: cfg.circuit_failure_threshold,
134
+ reset_timeout: cfg.circuit_reset_timeout,
135
+ on_state_change: method(:on_circuit_state_change),
136
+ )
137
+
138
+ @event_queue = EventQueue.new(
139
+ max_size: cfg.event_queue_size,
140
+ strategy: cfg.backpressure_strategy,
141
+ on_drop: method(:on_event_dropped),
142
+ )
143
+
144
+ Logging.info("LiveQuery client initialized", url: @url, application_id: @application_id)
145
+
146
+ connect if @auto_connect && @url
147
+ end
148
+
149
+ # Connect to the LiveQuery server
150
+ # @return [Boolean] true if connection initiated
151
+ def connect
152
+ return true if connected? || connecting?
153
+
154
+ # Check circuit breaker before attempting connection
155
+ unless @circuit_breaker.allow_request?
156
+ time_remaining = @circuit_breaker.time_until_half_open
157
+ Logging.warn("Connection blocked by circuit breaker",
158
+ state: @circuit_breaker.state,
159
+ time_until_retry: time_remaining)
160
+ emit(:circuit_open, time_remaining)
161
+ schedule_reconnect if @auto_reconnect
162
+ return false
163
+ end
164
+
165
+ @monitor.synchronize do
166
+ @state = :connecting
167
+ end
168
+
169
+ begin
170
+ Logging.info("Connecting to LiveQuery server", url: @url)
171
+ establish_connection
172
+ start_reader_thread
173
+ send_connect_message
174
+ true
175
+ rescue => e
176
+ @circuit_breaker.record_failure
177
+ @state = :disconnected
178
+ Logging.error("Failed to connect", error: e)
179
+ emit(:error, ConnectionError.new("Failed to connect: #{e.message}"))
180
+ schedule_reconnect if @auto_reconnect
181
+ false
182
+ end
183
+ end
184
+
185
+ # Disconnect from the LiveQuery server
186
+ # @param code [Integer] WebSocket close code
187
+ # @param reason [String] close reason
188
+ def close(code: 1000, reason: "Client closing")
189
+ @auto_reconnect = false
190
+ @monitor.synchronize do
191
+ return if @state == :closed
192
+
193
+ Logging.info("Closing connection", code: code, reason: reason)
194
+ send_close_frame(code, reason) if @socket
195
+ cleanup_connection
196
+ @state = :closed
197
+ end
198
+ emit(:close)
199
+ end
200
+
201
+ # Graceful shutdown with timeout
202
+ # @param timeout [Float] seconds to wait for graceful shutdown
203
+ # @return [void]
204
+ def shutdown(timeout: 5.0)
205
+ Logging.info("Shutting down LiveQuery client", timeout: timeout)
206
+
207
+ @auto_reconnect = false
208
+
209
+ # Cancel any pending reconnect thread
210
+ cancel_reconnect_thread
211
+
212
+ # Stop health monitor
213
+ @health_monitor.stop
214
+
215
+ # Stop event queue and drain remaining events
216
+ @event_queue.stop(drain: true, timeout: timeout / 2)
217
+
218
+ # Close connection
219
+ close(code: 1000, reason: "Shutdown")
220
+
221
+ # Wait for reader thread to finish
222
+ @reader_thread&.join(timeout / 2)
223
+
224
+ # Force kill if still running
225
+ @reader_thread&.kill
226
+ @reader_thread = nil
227
+
228
+ Logging.info("Shutdown complete",
229
+ events_processed: @event_queue.processed_count,
230
+ events_dropped: @event_queue.dropped_count)
231
+ end
232
+
233
+ # @return [Boolean] true if connected
234
+ def connected?
235
+ @state == :connected
236
+ end
237
+
238
+ # @return [Boolean] true if connecting
239
+ def connecting?
240
+ @state == :connecting
241
+ end
242
+
243
+ # @return [Boolean] true if closed
244
+ def closed?
245
+ @state == :closed
246
+ end
247
+
248
+ # Check if connection is healthy
249
+ # @return [Boolean]
250
+ def healthy?
251
+ connected? && @health_monitor.healthy?
252
+ end
253
+
254
+ # Get comprehensive health information
255
+ # @return [Hash]
256
+ def health_info
257
+ {
258
+ state: @state,
259
+ connected: connected?,
260
+ healthy: healthy?,
261
+ client_id: @client_id,
262
+ subscription_count: @subscriptions.size,
263
+ max_message_size: @max_message_size,
264
+ health_monitor: @health_monitor.health_info,
265
+ circuit_breaker: @circuit_breaker.info,
266
+ event_queue: @event_queue.stats,
267
+ }
268
+ end
269
+
270
+ # Subscribe to a Parse class with optional query constraints
271
+ # @param class_name [String, Class] Parse class name or model class
272
+ # @param where [Hash] query constraints
273
+ # @param fields [Array<String>] specific fields to watch
274
+ # @param session_token [String] session token for ACL-aware subscriptions
275
+ # @return [Subscription]
276
+ def subscribe(class_name, where: {}, fields: nil, session_token: nil)
277
+ # Handle Parse::Object subclass
278
+ if class_name.is_a?(Class) && class_name < Parse::Object
279
+ class_name = class_name.parse_class
280
+ end
281
+
282
+ # Handle Parse::Query object
283
+ if class_name.is_a?(Parse::Query)
284
+ query = class_name
285
+ class_name = query.table
286
+ where = query.compile_where
287
+ end
288
+
289
+ # Refuse server-side-JS / data-mutating operators in the `where`
290
+ # filter at any nesting depth. LiveQuery subscriptions are a
291
+ # persistent server-evaluated channel; without this gate, a
292
+ # caller could plant `$where`/`$function`/`$accumulator` (or
293
+ # data-mutating stages nested inside) and have them re-evaluated
294
+ # on every matching event for the lifetime of the subscription.
295
+ # Permissive mode (recursive denylist only) mirrors the
296
+ # `Parse::MongoDB`/`Parse::AtlasSearch` filter posture so the
297
+ # SDK enforces one consistent set of refusals on every
298
+ # user-influenced filter path.
299
+ Parse::PipelineSecurity.validate_filter!(where) if where.is_a?(Hash) && !where.empty?
300
+
301
+ subscription = Subscription.new(
302
+ client: self,
303
+ class_name: class_name,
304
+ query: where,
305
+ fields: fields,
306
+ session_token: session_token,
307
+ )
308
+
309
+ @monitor.synchronize do
310
+ @subscriptions[subscription.request_id] = subscription
311
+ end
312
+
313
+ Logging.debug("Subscription created",
314
+ request_id: subscription.request_id,
315
+ class_name: class_name)
316
+
317
+ # Send subscribe message if connected
318
+ if connected?
319
+ send_message(subscription.to_subscribe_message)
320
+ else
321
+ # Queue subscription for when connection is established
322
+ connect unless connecting?
323
+ end
324
+
325
+ subscription
326
+ end
327
+
328
+ # Unsubscribe from a subscription
329
+ # @param subscription [Subscription]
330
+ def unsubscribe(subscription)
331
+ Logging.debug("Unsubscribing", request_id: subscription.request_id)
332
+ send_message(subscription.to_unsubscribe_message) if connected?
333
+
334
+ @monitor.synchronize do
335
+ @subscriptions.delete(subscription.request_id)
336
+ end
337
+ end
338
+
339
+ # Register callback for connection events
340
+ # @param event [Symbol] :open, :close, :error, :circuit_open, :circuit_closed
341
+ # @yield callback block
342
+ def on(event, &block)
343
+ @monitor.synchronize do
344
+ @callbacks[event] << block if block_given?
345
+ end
346
+ self
347
+ end
348
+
349
+ # Callback for connection opened
350
+ def on_open(&block)
351
+ on(:open, &block)
352
+ end
353
+
354
+ # Callback for connection closed
355
+ def on_close(&block)
356
+ on(:close, &block)
357
+ end
358
+
359
+ # Callback for errors
360
+ def on_error(&block)
361
+ on(:error, &block)
362
+ end
363
+
364
+ private
365
+
366
+ # Get configuration object
367
+ # @return [Configuration]
368
+ def config
369
+ LiveQuery.config
370
+ end
371
+
372
+ # Safely get a value from the default Parse::Client if it exists
373
+ # @param method [Symbol] the method to call on the client
374
+ # @return [Object, nil] the value or nil if client not configured
375
+ def parse_client_value(method)
376
+ return nil unless Parse::Client.client?
377
+ Parse::Client.client.send(method)
378
+ rescue Parse::Error::ConnectionError
379
+ nil
380
+ end
381
+
382
+ # Loopback hostnames exempt from the `ws://` refusal in
383
+ # {#derive_websocket_url}. These addresses can't reach the
384
+ # Internet, so the cleartext-credentials threat model doesn't
385
+ # apply — but we still emit a warning so the operator knows
386
+ # they're on `ws://`.
387
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1] 0.0.0.0].freeze
388
+
389
+ # Derive WebSocket URL from Parse server URL. Refuses to
390
+ # synthesize a `ws://` URL from an `http://` server URL on any
391
+ # non-loopback host unless the LiveQuery configuration has
392
+ # `allow_insecure = true`. The master key and session tokens
393
+ # travel in the connect frame; downgrading to cleartext WebSocket
394
+ # on a routable host is an MITM-grade leak.
395
+ def derive_websocket_url
396
+ server_url = parse_client_value(:server_url)
397
+ return nil unless server_url
398
+
399
+ uri = URI.parse(server_url)
400
+ scheme = uri.scheme == "https" ? "wss" : "ws"
401
+ host = uri.host.to_s
402
+
403
+ if scheme == "ws" && !LOOPBACK_HOSTS.include?(host)
404
+ if config.allow_insecure
405
+ warn "[Parse::LiveQuery] Deriving insecure ws:// URL for #{host} " \
406
+ "(allow_insecure is enabled). Master key and session tokens " \
407
+ "will traverse a cleartext socket."
408
+ else
409
+ raise ArgumentError,
410
+ "[Parse::LiveQuery] Refusing to derive insecure ws:// URL from " \
411
+ "http:// server URL (#{server_url.inspect}). The LiveQuery " \
412
+ "connect frame carries the master key and any session token " \
413
+ "in plaintext on this socket. Use an https:// Parse server URL " \
414
+ "(so wss:// is derived) or pass an explicit wss:// `url:` to " \
415
+ "Parse::LiveQuery::Client.new. To opt into cleartext for local " \
416
+ "development on a routable host, set " \
417
+ "`Parse::LiveQuery.configure { |c| c.allow_insecure = true }`."
418
+ end
419
+ end
420
+
421
+ "#{scheme}://#{host}:#{uri.port || (scheme == "wss" ? 443 : 80)}"
422
+ end
423
+
424
+ # Establish TCP/SSL connection and perform WebSocket handshake
425
+ def establish_connection
426
+ uri = URI.parse(@url)
427
+ host = uri.host
428
+ port = uri.port || (uri.scheme == "wss" ? 443 : 80)
429
+ path = uri.path.empty? ? "/" : uri.path
430
+
431
+ # Create TCP socket
432
+ tcp_socket = TCPSocket.new(host, port)
433
+
434
+ begin
435
+ # Wrap with SSL if wss://
436
+ if uri.scheme == "wss"
437
+ ssl_context = OpenSSL::SSL::SSLContext.new
438
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
439
+ ssl_context.cert_store = OpenSSL::X509::Store.new
440
+ ssl_context.cert_store.set_default_paths
441
+
442
+ # Apply TLS version constraints from configuration
443
+ cfg = config
444
+ if cfg.ssl_min_version
445
+ ssl_context.min_version = Configuration.tls_version_constant(cfg.ssl_min_version)
446
+ end
447
+ if cfg.ssl_max_version
448
+ ssl_context.max_version = Configuration.tls_version_constant(cfg.ssl_max_version)
449
+ end
450
+
451
+ @socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
452
+ @socket.sync_close = true
453
+ @socket.hostname = host
454
+ @socket.connect
455
+ # SNI does not verify the cert matches the hostname; this does.
456
+ # Without it, any cert signed by a trusted CA for any host would be
457
+ # accepted, enabling MITM of LiveQuery sessions.
458
+ @socket.post_connection_check(host)
459
+ else
460
+ @socket = tcp_socket
461
+ end
462
+
463
+ # Perform WebSocket handshake
464
+ perform_handshake(host, path)
465
+ rescue
466
+ # Clean up both sockets on any failure mid-handshake. sync_close=true
467
+ # would close tcp_socket via @socket.close, but if @socket wasn't yet
468
+ # assigned (or assignment failed), we still need to close tcp_socket.
469
+ if @socket
470
+ begin
471
+ @socket.close
472
+ rescue StandardError
473
+ end
474
+ @socket = nil
475
+ else
476
+ begin
477
+ tcp_socket.close
478
+ rescue StandardError
479
+ end
480
+ end
481
+ raise
482
+ end
483
+ end
484
+
485
+ # RFC 6455 §1.3 magic GUID used to derive +Sec-WebSocket-Accept+
486
+ # from the client-supplied +Sec-WebSocket-Key+. Server proves it
487
+ # spoke WebSocket (and not e.g. a confused HTTP/1.1 server that
488
+ # happens to return +HTTP/1.1 101+) by echoing
489
+ # +Base64(SHA1(key || GUID))+ back to the client.
490
+ WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
491
+
492
+ # @!visibility private
493
+ # Cap on bytes read during the pre-handshake header phase, so a
494
+ # malicious server cannot stream unbounded headers and OOM the
495
+ # Ruby process before {Frame#read_frame}'s message-size cap kicks
496
+ # in.
497
+ HANDSHAKE_MAX_BYTES = 16 * 1024
498
+ # @!visibility private
499
+ HANDSHAKE_PER_LINE_BYTES = 8 * 1024
500
+
501
+ # Perform WebSocket handshake. Verifies the server's
502
+ # +Sec-WebSocket-Accept+ matches the SHA-1 of the random key +
503
+ # the WebSocket magic GUID, preventing cross-protocol acceptance
504
+ # of any HTTP/1.1 101 response from a non-WebSocket server.
505
+ def perform_handshake(host, path)
506
+ key = Base64.strict_encode64(SecureRandom.random_bytes(16))
507
+ expected_accept = Base64.strict_encode64(
508
+ Digest::SHA1.digest(key + WEBSOCKET_GUID)
509
+ )
510
+
511
+ handshake = [
512
+ "GET #{path} HTTP/1.1",
513
+ "Host: #{host}",
514
+ "Upgrade: websocket",
515
+ "Connection: Upgrade",
516
+ "Sec-WebSocket-Key: #{key}",
517
+ "Sec-WebSocket-Version: 13",
518
+ "Sec-WebSocket-Protocol: graphql-ws",
519
+ "",
520
+ ].join("\r\n") + "\r\n"
521
+
522
+ @socket.write(handshake)
523
+
524
+ # Read response with strict byte caps so a hostile/MITM server
525
+ # cannot stream unbounded header bytes pre-handshake.
526
+ response = +""
527
+ loop do
528
+ if response.bytesize > HANDSHAKE_MAX_BYTES
529
+ raise ConnectionError,
530
+ "WebSocket handshake exceeded #{HANDSHAKE_MAX_BYTES} bytes"
531
+ end
532
+ line = @socket.gets("\r\n", HANDSHAKE_PER_LINE_BYTES)
533
+ break if line.nil?
534
+ response << line
535
+ break if line == "\r\n"
536
+ end
537
+
538
+ validate_handshake_response!(response, expected_accept)
539
+ Logging.debug("WebSocket handshake complete")
540
+ end
541
+
542
+ # @!visibility private
543
+ # Parses and validates the server handshake response per RFC 6455 §4.1.
544
+ # Refuses any response that does not (a) start with HTTP/1.1 101,
545
+ # (b) carry +Upgrade: websocket+, (c) carry +Connection: Upgrade+,
546
+ # and (d) carry +Sec-WebSocket-Accept: <expected>+ matching the
547
+ # client-derived value.
548
+ def validate_handshake_response!(response, expected_accept)
549
+ if response.empty?
550
+ raise ConnectionError, "WebSocket handshake: empty server response"
551
+ end
552
+ unless response =~ /\AHTTP\/1\.[01]\s+101[ \t]/
553
+ raise ConnectionError,
554
+ "WebSocket handshake: expected HTTP 101 status, got: #{response.lines.first.to_s.strip.inspect}"
555
+ end
556
+ headers = {}
557
+ response.lines.drop(1).each do |line|
558
+ break if line == "\r\n"
559
+ name, _, value = line.chomp.partition(":")
560
+ headers[name.strip.downcase] = value.strip if name && value
561
+ end
562
+ unless headers["upgrade"]&.casecmp?("websocket")
563
+ raise ConnectionError,
564
+ "WebSocket handshake: missing or wrong Upgrade header (got #{headers["upgrade"].inspect})"
565
+ end
566
+ # Connection header may be a comma-separated list; check that
567
+ # "upgrade" appears among the tokens.
568
+ conn_tokens = headers["connection"].to_s.downcase.split(",").map(&:strip)
569
+ unless conn_tokens.include?("upgrade")
570
+ raise ConnectionError,
571
+ "WebSocket handshake: missing Upgrade token in Connection header"
572
+ end
573
+ accept = headers["sec-websocket-accept"]
574
+ unless accept && secure_str_compare(accept, expected_accept)
575
+ raise ConnectionError,
576
+ "WebSocket handshake: Sec-WebSocket-Accept mismatch — refusing to treat connection as WebSocket"
577
+ end
578
+ end
579
+
580
+ # @!visibility private
581
+ # Constant-time string comparison. Falls back to ActiveSupport if
582
+ # available, otherwise rolls a short manual implementation.
583
+ def secure_str_compare(a, b)
584
+ if defined?(ActiveSupport::SecurityUtils)
585
+ ActiveSupport::SecurityUtils.secure_compare(a, b)
586
+ else
587
+ return false unless a.bytesize == b.bytesize
588
+ l = a.unpack("C*")
589
+ res = 0
590
+ b.each_byte.with_index { |byte, i| res |= byte ^ l[i] }
591
+ res.zero?
592
+ end
593
+ end
594
+
595
+ # Start background thread for reading messages
596
+ def start_reader_thread
597
+ @reader_thread = Thread.new do
598
+ read_loop
599
+ end
600
+ @reader_thread.abort_on_exception = false
601
+ end
602
+
603
+ # Main read loop
604
+ def read_loop
605
+ while @socket && !@socket.closed?
606
+ begin
607
+ frame = read_frame
608
+ handle_frame(frame) if frame
609
+ rescue IOError, Errno::ECONNRESET, EOFError => e
610
+ Logging.debug("Connection closed", reason: e.class.name)
611
+ break
612
+ rescue => e
613
+ Logging.error("Read loop error", error: e)
614
+ emit(:error, e)
615
+ break
616
+ end
617
+ end
618
+
619
+ handle_disconnect
620
+ end
621
+
622
+ # Read a WebSocket frame with timeout protection
623
+ def read_frame
624
+ first_byte = read_with_timeout(1)
625
+ return nil unless first_byte
626
+
627
+ first_byte = first_byte.unpack1("C")
628
+ fin = (first_byte & 0x80) != 0
629
+ opcode = first_byte & 0x0F
630
+
631
+ second_byte = read_with_timeout(1).unpack1("C")
632
+ masked = (second_byte & 0x80) != 0
633
+ length = second_byte & 0x7F
634
+
635
+ if length == 126
636
+ length = read_with_timeout(2).unpack1("n")
637
+ elsif length == 127
638
+ length = read_with_timeout(8).unpack1("Q>")
639
+ end
640
+
641
+ # Prevent memory exhaustion from oversized frames
642
+ if length > @max_message_size
643
+ Logging.error("Message size exceeds limit",
644
+ size: length,
645
+ max_size: @max_message_size)
646
+ raise ConnectionError, "Message size #{length} exceeds maximum allowed #{@max_message_size}"
647
+ end
648
+
649
+ mask_key = masked ? read_with_timeout(4) : nil
650
+ payload = length > 0 ? read_with_timeout(length) : ""
651
+
652
+ if masked && payload && mask_key
653
+ payload = payload.bytes.each_with_index.map do |byte, i|
654
+ byte ^ mask_key.bytes[i % 4]
655
+ end.pack("C*")
656
+ end
657
+
658
+ { fin: fin, opcode: opcode, payload: payload }
659
+ end
660
+
661
+ # Read from socket with timeout protection
662
+ # @param length [Integer] number of bytes to read
663
+ # @return [String] the data read
664
+ # @raise [ConnectionError] if read times out
665
+ def read_with_timeout(length)
666
+ return @socket.read(length) unless @frame_read_timeout && @frame_read_timeout > 0
667
+
668
+ Timeout.timeout(@frame_read_timeout) do
669
+ @socket.read(length)
670
+ end
671
+ rescue Timeout::Error
672
+ Logging.error("Frame read timeout", timeout: @frame_read_timeout)
673
+ raise ConnectionError, "Frame read timed out after #{@frame_read_timeout} seconds"
674
+ end
675
+
676
+ # Handle a WebSocket frame
677
+ def handle_frame(frame)
678
+ # Record activity for health monitoring
679
+ @health_monitor.record_activity
680
+
681
+ case frame[:opcode]
682
+ when OPCODE_TEXT
683
+ handle_message(frame[:payload])
684
+ when OPCODE_PING
685
+ send_pong(frame[:payload])
686
+ when OPCODE_PONG
687
+ @health_monitor.record_pong
688
+ when OPCODE_CLOSE
689
+ handle_close_frame(frame[:payload])
690
+ end
691
+ end
692
+
693
+ # Handle incoming text message
694
+ def handle_message(data)
695
+ return unless data
696
+
697
+ begin
698
+ message = JSON.parse(data)
699
+ process_server_message(message)
700
+ rescue JSON::ParserError => e
701
+ Logging.error("Failed to parse message", error: e, data: data)
702
+ emit(:error, e)
703
+ end
704
+ end
705
+
706
+ # Process a server message
707
+ def process_server_message(message)
708
+ op = message["op"]
709
+
710
+ case op
711
+ when "connected"
712
+ handle_connected(message)
713
+ when "subscribed"
714
+ handle_subscribed(message)
715
+ when "unsubscribed"
716
+ handle_unsubscribed(message)
717
+ when "create", "update", "delete", "enter", "leave"
718
+ handle_event(op, message)
719
+ when "error"
720
+ handle_server_error(message)
721
+ end
722
+ end
723
+
724
+ # Handle connected message from server
725
+ def handle_connected(message)
726
+ @client_id = message["clientId"]
727
+ @monitor.synchronize do
728
+ @state = :connected
729
+ @reconnect_interval = config.initial_reconnect_interval
730
+ end
731
+
732
+ # Record successful connection
733
+ @circuit_breaker.record_success
734
+
735
+ # Start health monitoring
736
+ @health_monitor.start
737
+
738
+ # Start event queue processing
739
+ @event_queue.start { |event| dispatch_event(event) }
740
+
741
+ Logging.info("Connected to LiveQuery server", client_id: @client_id)
742
+ emit(:open)
743
+
744
+ # Send pending subscriptions
745
+ resubscribe_all
746
+ end
747
+
748
+ # Handle subscription confirmed
749
+ def handle_subscribed(message)
750
+ request_id = message["requestId"]
751
+ subscription = @subscriptions[request_id]
752
+ if subscription
753
+ subscription.confirm!
754
+ Logging.debug("Subscription confirmed", request_id: request_id)
755
+ end
756
+ end
757
+
758
+ # Handle unsubscription confirmed
759
+ def handle_unsubscribed(message)
760
+ request_id = message["requestId"]
761
+ @monitor.synchronize do
762
+ @subscriptions.delete(request_id)
763
+ end
764
+ Logging.debug("Unsubscription confirmed", request_id: request_id)
765
+ end
766
+
767
+ # Handle data event (create/update/delete/enter/leave)
768
+ # Routes through event queue for backpressure handling
769
+ def handle_event(op, message)
770
+ request_id = message["requestId"]
771
+ subscription = @subscriptions[request_id]
772
+ return unless subscription
773
+
774
+ event = Event.new(
775
+ type: op.to_sym,
776
+ class_name: message.dig("object", "className") || subscription.class_name,
777
+ object_data: message["object"],
778
+ original_data: message["original"],
779
+ request_id: request_id,
780
+ raw: message,
781
+ )
782
+
783
+ # Route through event queue for backpressure handling
784
+ @event_queue.enqueue({ subscription: subscription, event: event })
785
+ end
786
+
787
+ # Dispatch event to subscription (called from event queue processor)
788
+ # @param item [Hash] contains :subscription and :event
789
+ def dispatch_event(item)
790
+ subscription = item[:subscription]
791
+ event = item[:event]
792
+ subscription.handle_event(event)
793
+ rescue => e
794
+ Logging.error("Event dispatch error", error: e, event_type: event.type)
795
+ end
796
+
797
+ # Handle server error
798
+ def handle_server_error(message)
799
+ request_id = message["requestId"]
800
+ error_message = message["error"] || "Unknown server error"
801
+ code = message["code"]
802
+
803
+ Logging.error("Server error", error: error_message, code: code, request_id: request_id)
804
+
805
+ if request_id && @subscriptions[request_id]
806
+ @subscriptions[request_id].fail!("#{error_message} (code: #{code})")
807
+ else
808
+ emit(:error, Error.new("#{error_message} (code: #{code})"))
809
+ end
810
+ end
811
+
812
+ # Handle close frame
813
+ def handle_close_frame(payload)
814
+ code = payload[0..1].unpack1("n") if payload && payload.length >= 2
815
+ Logging.debug("Received close frame", code: code)
816
+ cleanup_connection
817
+ end
818
+
819
+ # Handle disconnect
820
+ def handle_disconnect
821
+ was_connected = connected?
822
+ cleanup_connection
823
+
824
+ if was_connected
825
+ emit(:close)
826
+ schedule_reconnect if @auto_reconnect
827
+ end
828
+ end
829
+
830
+ # Cleanup connection resources
831
+ def cleanup_connection
832
+ # Stop health monitor
833
+ @health_monitor.stop
834
+
835
+ # Stop event queue (but don't drain during disconnect - we may reconnect)
836
+ @event_queue.stop(drain: false)
837
+
838
+ @monitor.synchronize do
839
+ @state = :disconnected unless @state == :closed
840
+ @socket&.close rescue nil
841
+ @socket = nil
842
+ end
843
+
844
+ Logging.debug("Connection cleaned up")
845
+ end
846
+
847
+ # Schedule reconnection with exponential backoff and jitter
848
+ def schedule_reconnect
849
+ return if @state == :closed
850
+
851
+ # Cancel any existing reconnect thread to prevent accumulation
852
+ cancel_reconnect_thread
853
+
854
+ cfg = config
855
+ jitter_factor = cfg.reconnect_jitter
856
+ jitter = @reconnect_interval * jitter_factor * (rand - 0.5) * 2
857
+ delay = @reconnect_interval + jitter
858
+ delay = [delay, 0.1].max # Ensure positive delay
859
+
860
+ Logging.info("Scheduling reconnect", delay: delay.round(2))
861
+
862
+ @reconnect_thread = Thread.new do
863
+ sleep delay
864
+ @monitor.synchronize do
865
+ @reconnect_thread = nil
866
+ end
867
+ @reconnect_interval = [@reconnect_interval * cfg.reconnect_multiplier,
868
+ cfg.max_reconnect_interval].min
869
+ connect
870
+ end
871
+ end
872
+
873
+ # Cancel any pending reconnect thread
874
+ def cancel_reconnect_thread
875
+ @monitor.synchronize do
876
+ if @reconnect_thread&.alive?
877
+ @reconnect_thread.kill
878
+ @reconnect_thread = nil
879
+ end
880
+ end
881
+ end
882
+
883
+ # Resubscribe all pending subscriptions
884
+ def resubscribe_all
885
+ subs = @monitor.synchronize { @subscriptions.values.dup }
886
+ subs.each do |subscription|
887
+ send_message(subscription.to_subscribe_message)
888
+ end
889
+ Logging.debug("Resubscribed to all subscriptions", count: subs.size)
890
+ end
891
+
892
+ # Send connect message to server
893
+ def send_connect_message
894
+ message = {
895
+ op: "connect",
896
+ applicationId: @application_id,
897
+ }
898
+
899
+ message[:clientKey] = @client_key if @client_key
900
+ message[:masterKey] = @master_key if @master_key
901
+
902
+ send_message(message)
903
+ end
904
+
905
+ # Send a message through the WebSocket
906
+ def send_message(message)
907
+ data = message.is_a?(String) ? message : message.to_json
908
+ send_frame(OPCODE_TEXT, data)
909
+ end
910
+
911
+ # Send a WebSocket frame
912
+ def send_frame(opcode, data)
913
+ @monitor.synchronize do
914
+ return unless @socket && !@socket.closed?
915
+
916
+ bytes = data.bytes
917
+ length = bytes.length
918
+
919
+ # Build frame
920
+ frame = [0x80 | opcode].pack("C") # FIN + opcode
921
+
922
+ # Length with mask bit set (client must mask)
923
+ if length < 126
924
+ frame += [0x80 | length].pack("C")
925
+ elsif length < 65536
926
+ frame += [0x80 | 126, length].pack("Cn")
927
+ else
928
+ frame += [0x80 | 127, length].pack("CQ>")
929
+ end
930
+
931
+ # Generate mask key and apply
932
+ mask = SecureRandom.random_bytes(4)
933
+ frame += mask
934
+
935
+ masked_data = bytes.each_with_index.map do |byte, i|
936
+ byte ^ mask.bytes[i % 4]
937
+ end.pack("C*")
938
+
939
+ frame += masked_data
940
+
941
+ @socket.write(frame)
942
+ end
943
+ end
944
+
945
+ # Send ping frame (called by health monitor)
946
+ def send_ping
947
+ Logging.debug("Sending ping")
948
+ send_frame(OPCODE_PING, "")
949
+ end
950
+
951
+ # Send pong frame
952
+ def send_pong(data)
953
+ send_frame(OPCODE_PONG, data || "")
954
+ end
955
+
956
+ # Send close frame
957
+ def send_close_frame(code, reason)
958
+ data = [code].pack("n") + reason.to_s
959
+ send_frame(OPCODE_CLOSE, data)
960
+ end
961
+
962
+ # Handle stale connection (called by health monitor)
963
+ def handle_stale_connection
964
+ Logging.warn("Connection stale, triggering reconnect")
965
+ cleanup_connection
966
+ schedule_reconnect if @auto_reconnect
967
+ end
968
+
969
+ # Circuit breaker state change callback
970
+ def on_circuit_state_change(old_state, new_state)
971
+ Logging.info("Circuit breaker state change", from: old_state, to: new_state)
972
+ case new_state
973
+ when :open
974
+ emit(:circuit_open, @circuit_breaker.time_until_half_open)
975
+ when :closed
976
+ emit(:circuit_closed)
977
+ end
978
+ end
979
+
980
+ # Event dropped callback
981
+ def on_event_dropped(event, reason)
982
+ Logging.warn("Event dropped due to backpressure",
983
+ reason: reason,
984
+ event_type: event[:event]&.type)
985
+ end
986
+
987
+ # Emit event to callbacks (thread-safe)
988
+ def emit(event, *args)
989
+ # Copy callbacks under lock, iterate outside to prevent deadlocks
990
+ callbacks = @monitor.synchronize { @callbacks[event].dup }
991
+ callbacks.each do |callback|
992
+ begin
993
+ callback.call(*args)
994
+ rescue => e
995
+ Logging.error("Callback error", event: event, error: e)
996
+ end
997
+ end
998
+ end
999
+ end
1000
+ end
1001
+ end