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,310 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ # Multi-Factor Authentication (MFA) support for Parse Server.
6
+ #
7
+ # This module interfaces with Parse Server's built-in MFA adapter which supports
8
+ # TOTP (Time-based One-Time Password) and SMS-based authentication.
9
+ #
10
+ # == Parse Server Configuration
11
+ #
12
+ # MFA must be enabled in your Parse Server configuration:
13
+ #
14
+ # {
15
+ # auth: {
16
+ # mfa: {
17
+ # enabled: true,
18
+ # options: ["TOTP"], // or ["SMS", "TOTP"]
19
+ # digits: 6,
20
+ # period: 30,
21
+ # algorithm: "SHA1"
22
+ # }
23
+ # }
24
+ # }
25
+ #
26
+ # == TOTP Setup Flow
27
+ #
28
+ # 1. Generate a secret client-side using {MFA.generate_secret}
29
+ # 2. Display QR code to user using {MFA.provisioning_uri} or {MFA.qr_code}
30
+ # 3. User scans QR with authenticator app (Google Authenticator, Authy, etc.)
31
+ # 4. User enters the 6-digit code from their app
32
+ # 5. Call {User#setup_mfa!} with secret and token to enable MFA
33
+ # 6. Store the recovery codes returned - user needs these for account recovery!
34
+ #
35
+ # @example Enable TOTP MFA for a user
36
+ # # Step 1: Generate secret
37
+ # secret = Parse::MFA.generate_secret
38
+ #
39
+ # # Step 2: Show QR code to user
40
+ # qr_svg = Parse::MFA.qr_code(secret, user.email, issuer: "MyApp")
41
+ # # render qr_svg in your UI
42
+ #
43
+ # # Step 3-4: User scans and enters code
44
+ # token = params[:totp_code] # "123456" from authenticator app
45
+ #
46
+ # # Step 5: Enable MFA
47
+ # recovery_codes = user.setup_mfa!(secret: secret, token: token)
48
+ # # => "ABC123DEF456..., XYZ789..."
49
+ #
50
+ # # Step 6: Show recovery codes to user (one time only!)
51
+ #
52
+ # @example Login with MFA
53
+ # user = Parse::User.login_with_mfa("username", "password", "123456")
54
+ #
55
+ # @see https://github.com/parse-community/parse-server/blob/master/src/Adapters/Auth/mfa.js
56
+ #
57
+ module MFA
58
+ # Error raised when MFA verification fails
59
+ class VerificationError < Parse::Error
60
+ def initialize(message = "Invalid MFA token")
61
+ super(message)
62
+ end
63
+ end
64
+
65
+ # Error raised when MFA is required but not provided
66
+ class RequiredError < Parse::Error
67
+ def initialize(message = "MFA token is required for this account")
68
+ super(message)
69
+ end
70
+ end
71
+
72
+ # Error raised when MFA is already set up
73
+ class AlreadyEnabledError < Parse::Error
74
+ def initialize(message = "MFA is already set up on this account")
75
+ super(message)
76
+ end
77
+ end
78
+
79
+ # Error raised when required gem is not available
80
+ class DependencyError < Parse::Error
81
+ def initialize(gem_name)
82
+ super("The '#{gem_name}' gem is required for this feature. Add to Gemfile: gem '#{gem_name}'")
83
+ end
84
+ end
85
+
86
+ # Error raised when an operator is not authorized to perform a
87
+ # privileged MFA operation (e.g. master-key disable). See
88
+ # {Parse::MFA::UserExtension#disable_mfa_master_key!}.
89
+ class ForbiddenError < Parse::Error
90
+ def initialize(message = "Not authorized to perform this MFA operation")
91
+ super(message)
92
+ end
93
+ end
94
+
95
+ # Default configuration
96
+ DEFAULT_CONFIG = {
97
+ issuer: "Parse App",
98
+ digits: 6,
99
+ period: 30,
100
+ algorithm: "SHA1",
101
+ secret_length: 20, # Minimum required by Parse Server
102
+ }.freeze
103
+
104
+ class << self
105
+ # Global MFA configuration
106
+ # @return [Hash]
107
+ def config
108
+ @config ||= DEFAULT_CONFIG.dup
109
+ end
110
+
111
+ # Configure MFA settings
112
+ # @yield [config] Configuration hash
113
+ # @example
114
+ # Parse::MFA.configure do |config|
115
+ # config[:issuer] = "My App"
116
+ # end
117
+ def configure
118
+ yield config if block_given?
119
+ config
120
+ end
121
+
122
+ # Check if rotp gem is available
123
+ # @return [Boolean]
124
+ def rotp_available?
125
+ require "rotp"
126
+ true
127
+ rescue LoadError
128
+ false
129
+ end
130
+
131
+ # Check if rqrcode gem is available
132
+ # @return [Boolean]
133
+ def rqrcode_available?
134
+ require "rqrcode"
135
+ true
136
+ rescue LoadError
137
+ false
138
+ end
139
+
140
+ # Generate a new TOTP secret for MFA setup.
141
+ # The secret must be at least 20 characters (Parse Server requirement).
142
+ #
143
+ # @param length [Integer] Secret length (minimum 20)
144
+ # @return [String] Base32-encoded secret
145
+ #
146
+ # @example
147
+ # secret = Parse::MFA.generate_secret
148
+ # # => "JBSWY3DPEHPK3PXP4QFAZJ7K"
149
+ def generate_secret(length: nil)
150
+ ensure_rotp!
151
+ length ||= config[:secret_length]
152
+ length = [length, 20].max # Parse Server requires minimum 20
153
+ ROTP::Base32.random(length)
154
+ end
155
+
156
+ # Create a TOTP instance for verification.
157
+ #
158
+ # @param secret [String] Base32-encoded secret
159
+ # @param issuer [String] Optional issuer name
160
+ # @return [ROTP::TOTP]
161
+ def totp(secret, issuer: nil)
162
+ ensure_rotp!
163
+ ROTP::TOTP.new(
164
+ secret,
165
+ issuer: issuer || config[:issuer],
166
+ interval: config[:period],
167
+ digits: config[:digits],
168
+ )
169
+ end
170
+
171
+ # Verify a TOTP code locally (for testing/validation before sending to server).
172
+ #
173
+ # @param secret [String] Base32-encoded secret
174
+ # @param code [String] The 6-digit code to verify
175
+ # @return [Boolean] True if valid
176
+ #
177
+ # @example
178
+ # if Parse::MFA.verify(secret, "123456")
179
+ # puts "Code is valid!"
180
+ # end
181
+ def verify(secret, code)
182
+ return false if secret.blank? || code.blank?
183
+
184
+ ensure_rotp!
185
+ drift_seconds = config[:period]
186
+ totp_instance = totp(secret)
187
+ totp_instance.verify(code.to_s, drift_behind: drift_seconds, drift_ahead: drift_seconds).present?
188
+ end
189
+
190
+ # Get the current TOTP code (for testing/debugging).
191
+ #
192
+ # @param secret [String] Base32-encoded secret
193
+ # @return [String] Current 6-digit code
194
+ def current_code(secret)
195
+ ensure_rotp!
196
+ totp(secret).now
197
+ end
198
+
199
+ # Generate provisioning URI for authenticator apps.
200
+ #
201
+ # @param secret [String] Base32-encoded secret
202
+ # @param account_name [String] User identifier (email or username)
203
+ # @param issuer [String] Optional issuer override
204
+ # @return [String] otpauth:// URI
205
+ #
206
+ # @example
207
+ # uri = Parse::MFA.provisioning_uri(secret, "user@example.com", issuer: "MyApp")
208
+ # # => "otpauth://totp/MyApp:user@example.com?secret=ABC123&issuer=MyApp"
209
+ def provisioning_uri(secret, account_name, issuer: nil)
210
+ ensure_rotp!
211
+ totp(secret, issuer: issuer).provisioning_uri(account_name)
212
+ end
213
+
214
+ # Generate a QR code for the authenticator app.
215
+ #
216
+ # @param secret [String] Base32-encoded secret
217
+ # @param account_name [String] User identifier
218
+ # @param issuer [String] Optional issuer name
219
+ # @param format [Symbol] Output format (:svg, :png, :ascii)
220
+ # @return [String] QR code in specified format
221
+ #
222
+ # @example
223
+ # svg = Parse::MFA.qr_code(secret, user.email, issuer: "MyApp")
224
+ # # Render in HTML: <%= raw svg %>
225
+ def qr_code(secret, account_name, issuer: nil, format: :svg)
226
+ ensure_rqrcode!
227
+ uri = provisioning_uri(secret, account_name, issuer: issuer)
228
+ qr = RQRCode::QRCode.new(uri)
229
+
230
+ case format
231
+ when :svg
232
+ qr.as_svg(
233
+ color: "000",
234
+ shape_rendering: "crispEdges",
235
+ module_size: 4,
236
+ standalone: true,
237
+ )
238
+ when :png
239
+ qr.as_png(size: 300)
240
+ when :ascii
241
+ qr.as_ansi
242
+ else
243
+ qr.as_svg
244
+ end
245
+ end
246
+
247
+ # Build authData hash for MFA setup.
248
+ #
249
+ # @param secret [String] Base32-encoded TOTP secret
250
+ # @param token [String] Current TOTP code for verification
251
+ # @return [Hash] authData for Parse Server
252
+ def build_setup_auth_data(secret:, token:)
253
+ {
254
+ mfa: {
255
+ secret: secret,
256
+ token: token,
257
+ },
258
+ }
259
+ end
260
+
261
+ # Build authData hash for MFA login.
262
+ #
263
+ # @param token [String] TOTP code or recovery code
264
+ # @return [Hash] authData for Parse Server
265
+ def build_login_auth_data(token:)
266
+ {
267
+ mfa: {
268
+ token: token,
269
+ },
270
+ }
271
+ end
272
+
273
+ # Build authData hash for SMS MFA setup.
274
+ #
275
+ # @param mobile [String] Phone number in E.164 format
276
+ # @return [Hash] authData for Parse Server
277
+ def build_sms_setup_auth_data(mobile:)
278
+ {
279
+ mfa: {
280
+ mobile: mobile,
281
+ },
282
+ }
283
+ end
284
+
285
+ # Build authData hash for SMS MFA confirmation.
286
+ #
287
+ # @param mobile [String] Phone number
288
+ # @param token [String] SMS code received
289
+ # @return [Hash] authData for Parse Server
290
+ def build_sms_confirm_auth_data(mobile:, token:)
291
+ {
292
+ mfa: {
293
+ mobile: mobile,
294
+ token: token,
295
+ },
296
+ }
297
+ end
298
+
299
+ private
300
+
301
+ def ensure_rotp!
302
+ raise DependencyError.new("rotp") unless rotp_available?
303
+ end
304
+
305
+ def ensure_rqrcode!
306
+ raise DependencyError.new("rqrcode") unless rqrcode_available?
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,360 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "active_model"
5
+ require "active_support"
6
+ require "active_support/inflector"
7
+ require "active_support/core_ext/object"
8
+ require "active_support/core_ext/string"
9
+ require "active_support/core_ext"
10
+ require "active_model/serializers/json"
11
+
12
+ module Parse
13
+ class Webhooks
14
+ # Represents the data structure that Parse server sends to a registered webhook.
15
+ # Parse Parse allows you to receive Cloud Code webhooks on your own hosted
16
+ # server. The `Parse::Webhooks` class is a lightweight Rack application that
17
+ # routes incoming Cloud Code webhook requests and payloads to locally
18
+ # registered handlers. The payloads are {Parse::Webhooks::Payload} type of objects that
19
+ # represent that data that Parse sends webhook handlers.
20
+ class Payload
21
+ # The set of keys that can be contained in a Parse hash payload for a webhook.
22
+ ATTRIBUTES = { master: nil, user: nil,
23
+ installationId: nil, params: nil,
24
+ functionName: nil, object: nil,
25
+ original: nil, update: nil,
26
+ query: nil, log: nil,
27
+ objects: nil,
28
+ triggerName: nil }.freeze
29
+ include ::ActiveModel::Serializers::JSON
30
+ # @!attribute [rw] master
31
+ # @return [Boolean] whether the master key was used for this request.
32
+ # @!attribute [rw] user
33
+ # @return [Parse::User] the user who performed this request or action.
34
+ # @!attribute [rw] installation_id
35
+ # @return [String] The identifier of the device that submitted the request.
36
+ # @!attribute [rw] params
37
+ # @return [Hash] The list of function arguments submitted for a function request.
38
+ # @!attribute [rw] function_name
39
+ # @return [String] the name of the function.
40
+ # @!attribute [rw] object
41
+ # In a beforeSave, this attribute is the final object that will be persisted.
42
+ # @return [Hash] the object hash related to a webhook trigger request.
43
+ # @see #parse_object
44
+ # @!attribute [rw] trigger_name
45
+ # @return [String] the name of the trigger (ex. beforeSave, afterSave, etc.)
46
+ # @!attribute [rw] original
47
+ # In a beforeSave, for previously saved objects, this attribute is the Parse::Object
48
+ # that was previously in the persistent store.
49
+ # @return [Hash] the object hash related to a webhook trigger request.
50
+ # @see #parse_object
51
+ # @!attribute [rw] raw
52
+ # @return [Hash] the raw payload from Parse server.
53
+ # @!attribute [rw] update
54
+ # @return [Hash] the update payload in the request.
55
+ # @!attribute [r] query
56
+ # The query request in a beforeFind trigger. Available in Parse Server 2.3.1 or later.
57
+ # @return [Parse::Query]
58
+ # @!attribute [r] objects
59
+ # The set of matching objects in an afterFind trigger. Available in Parse Server 2.3.1 or later.
60
+ # @return [<Parse::Object>]
61
+ # @!attribute [r] log
62
+ # Logging information if available. Available in Parse Server 2.3.1 or later.
63
+ # @return [Hash] the set of matching objects in an afterFind trigger.
64
+ attr_accessor :master, :user, :installation_id, :params, :function_name, :object, :trigger_name
65
+ attr_accessor :query, :log, :objects
66
+ attr_accessor :original, :update, :raw
67
+ # @!visibility private
68
+ attr_accessor :webhook_class
69
+ alias_method :installationId, :installation_id
70
+ alias_method :functionName, :function_name
71
+ alias_method :triggerName, :trigger_name
72
+
73
+ # You would normally never create a {Parse::Webhooks::Payload} object since it is automatically
74
+ # provided to you when using Parse::Webhooks.
75
+ # @see Parse::Webhooks
76
+ def initialize(hash = {})
77
+ hash = JSON.parse(hash, max_nesting: 20) if hash.is_a?(String)
78
+ hash = Hash[hash.map { |k, v| [k.to_s.underscore.to_sym, v] }]
79
+ @raw = hash
80
+ @master = hash[:master]
81
+ # Strip protected mass-assignment keys (sessionToken, _rperm, _wperm,
82
+ # _hashed_password, authData, roles, etc.) BEFORE constructing the
83
+ # user object. Without this, an attacker reaching the webhook
84
+ # endpoint with a valid key (or with the optional unauthenticated
85
+ # mode enabled) can forge any of these fields on +payload.user+
86
+ # via the +objectId+-present hydration branch that bypasses the
87
+ # +Parse::Object#apply_attributes!+ protected-key filter.
88
+ if hash[:user].present?
89
+ @user = Parse::User.new(self.class.scrub_protected_keys(hash[:user]))
90
+ end
91
+ @installation_id = hash[:installation_id]
92
+ @params = hash[:params]
93
+ @params = @params.with_indifferent_access if @params.is_a?(Hash)
94
+ @function_name = hash[:function_name]
95
+ @object = self.class.scrub_protected_keys(hash[:object])
96
+ @trigger_name = hash[:trigger_name]
97
+ @original = self.class.scrub_protected_keys(hash[:original])
98
+ @update = self.class.scrub_protected_keys(hash[:update]) || {}
99
+ # Added for beforeFind and afterFind triggers
100
+ @query = hash[:query]
101
+ @objects = hash[:objects] || []
102
+ @log = hash[:log]
103
+ end
104
+
105
+ # @!visibility private
106
+ # Routing metadata that must be preserved on payload hashes even
107
+ # though the general mass-assignment denylist forbids it. Stripping
108
+ # +className+ here breaks +parse_class+/+parse_object+ resolution and
109
+ # silently disables +payload_class_mismatch?+. The denylist still
110
+ # protects +Parse::Object#apply_attributes!+ at hydration time.
111
+ PAYLOAD_PRESERVED_KEYS = %w[className __type].freeze
112
+
113
+ # @!visibility private
114
+ # Returns a copy of +obj+ with the +PROTECTED_MASS_ASSIGNMENT_KEYS+
115
+ # removed, except for routing metadata in +PAYLOAD_PRESERVED_KEYS+.
116
+ # Operates on string and symbol keys (Parse Server uses camelCase
117
+ # strings on the wire; downstream code may have already symbolized).
118
+ # Pass-through for non-Hash input.
119
+ def self.scrub_protected_keys(obj)
120
+ return obj unless obj.is_a?(Hash)
121
+ denied = Parse::Properties::PROTECTED_MASS_ASSIGNMENT_KEYS
122
+ obj.reject do |k, _|
123
+ name = k.to_s
124
+ next false if PAYLOAD_PRESERVED_KEYS.include?(name)
125
+ denied.include?(name) || denied.include?(name.underscore)
126
+ end
127
+ end
128
+
129
+ # @return [ATTRIBUTES]
130
+ def attributes
131
+ ATTRIBUTES
132
+ end
133
+
134
+ # Method to print to standard that utilizes the an internal id to make it easier
135
+ # to trace incoming requests.
136
+ def wlog(s)
137
+ # generates a unique random number in order to be used in logging. This
138
+ # is useful when debugging issues in production where one server instance
139
+ # may be running multiple threads and you want to trace the incoming call.
140
+ @rid ||= rand(999).to_s.rjust(3)
141
+ puts "[> #{@rid}] #{s}"
142
+ @rid
143
+ end
144
+
145
+ # true if this is a webhook function request.
146
+ def function?
147
+ @function_name.present?
148
+ end
149
+
150
+ # true if the master key was used for this request.
151
+ def master?
152
+ @master.present?
153
+ end
154
+
155
+ # @return [String] the name of the Parse class for this request.
156
+ def parse_class
157
+ return @webhook_class if @webhook_class.present?
158
+ return nil unless @object.present?
159
+ @object[Parse::Model::KEY_CLASS_NAME] || @object[:className]
160
+ end
161
+
162
+ # @return [String] the objectId in this request.
163
+ def parse_id
164
+ return nil unless @object.present?
165
+ @object[Parse::Model::OBJECT_ID] || @object[:objectId]
166
+ end
167
+
168
+ alias_method :objectId, :parse_id
169
+
170
+ # true if this is a webhook trigger request.
171
+ def trigger?
172
+ @trigger_name.present?
173
+ end
174
+
175
+ # true if this is a beforeSave or beforeDelete webhook trigger request.
176
+ def before_trigger?
177
+ before_save? || before_delete? || before_find?
178
+ end
179
+
180
+ # true if this is a afterSave or afterDelete webhook trigger request.
181
+ def after_trigger?
182
+ after_save? || after_delete? || after_find?
183
+ end
184
+
185
+ # true if this is a beforeSave webhook trigger request.
186
+ def before_save?
187
+ trigger? && @trigger_name.to_sym == :beforeSave
188
+ end
189
+
190
+ # true if this is a afterSave webhook trigger request.
191
+ def after_save?
192
+ trigger? && @trigger_name.to_sym == :afterSave
193
+ end
194
+
195
+ # true if this is a beforeDelete webhook trigger request.
196
+ def before_delete?
197
+ trigger? && @trigger_name.to_sym == :beforeDelete
198
+ end
199
+
200
+ # true if this is a afterDelete webhook trigger request.
201
+ def after_delete?
202
+ trigger? && @trigger_name.to_sym == :afterDelete
203
+ end
204
+
205
+ # true if this is a beforeFind webhook trigger request.
206
+ def before_find?
207
+ trigger? && @trigger_name.to_sym == :beforeFind
208
+ end
209
+
210
+ # true if this is a afterFind webhook trigger request.
211
+ def after_find?
212
+ trigger? && @trigger_name.to_sym == :afterFind
213
+ end
214
+
215
+ # true if this request is a trigger that contains an object.
216
+ def object?
217
+ trigger? && @object.present?
218
+ end
219
+
220
+ # @return [Parse::Object] a Parse::Object from the original object
221
+ def original_parse_object
222
+ return nil unless @original.is_a?(Hash)
223
+ # Always pass the trigger's expected class explicitly so the
224
+ # className inside the payload cannot redirect this hydration to a
225
+ # different class.
226
+ Parse::Object.build(@original, parse_class)
227
+ end
228
+
229
+ # This method returns a Parse::Object by combining the original object, if was provided,
230
+ # with the final object. This will return a dirty tracked Parse::Object subclass,
231
+ # that will have information on which fields have changed between the previous state
232
+ # in the persistent store and the one about to be saved.
233
+ # @param pristine [Boolean] whether the object should be returned without dirty tracking.
234
+ # @return [Parse::Object] a dirty tracked Parse::Object subclass instance
235
+ def parse_object(pristine = false)
236
+ return nil unless object?
237
+ return Parse::Object.build(@object, parse_class) if pristine
238
+ # Memoize so pre-block guard application and the user webhook handler
239
+ # observe the same instance. Otherwise field_guards applied on the
240
+ # framework's pre-built object would be invisible to the block's
241
+ # later parse_object call (which would construct a fresh dirty-tracked
242
+ # object from @object/@original).
243
+ return @parse_object if defined?(@parse_object) && !@parse_object.nil?
244
+ @parse_object = build_parse_object
245
+ end
246
+
247
+ # @!visibility private
248
+ # Returns +true+ when +@object+/+@original+ contain a className that
249
+ # disagrees with the trigger's expected class. Used to skip building
250
+ # a typed object when the payload was clearly forged or routed
251
+ # incorrectly.
252
+ def payload_class_mismatch?
253
+ expected = parse_class
254
+ return false if expected.nil?
255
+ [@object, @original, @update].any? do |h|
256
+ h.is_a?(Hash) && h["className"] && h["className"] != expected
257
+ end
258
+ end
259
+
260
+ # Force a fresh build, discarding any memoized parse_object. Used by the
261
+ # webhook framework after mutating @object / @update so a subsequent
262
+ # parse_object call picks up the modified payload state.
263
+ # @!visibility private
264
+ def reset_parse_object_cache!
265
+ @parse_object = nil
266
+ end
267
+
268
+ private
269
+
270
+ def build_parse_object
271
+ # if its a before trigger, then we build the original object and apply the updates
272
+ # in order to create a Parse::Object that has the dirty tracking information
273
+ # if no original is nil, then it means this is a brand new object, so we create
274
+ # one from the className
275
+ if before_trigger?
276
+ # if original is present, then this is a modified object
277
+ if @original.present? && @original.is_a?(Hash)
278
+ o = Parse::Object.build @original, parse_class
279
+ o.apply_attributes! @object, dirty_track: true
280
+
281
+ if o.is_a?(Parse::User) && @update.present? && @update["authData"].present?
282
+ o.auth_data = @update["authData"]
283
+ end
284
+ return o
285
+ else #else the object must be new
286
+ klass = Parse::Object.find_class parse_class
287
+ # if we have a class, return that with updated changes, otherwise
288
+ # default to regular object
289
+ if klass.present?
290
+ o = klass.new(@object || {})
291
+ if o.is_a?(Parse::User) && @update.present? && @update["authData"].present?
292
+ o.auth_data = @update["authData"]
293
+ end
294
+ return o
295
+ end # if klass.present?
296
+ end # if we have original
297
+ end # if before_trigger?
298
+ Parse::Object.build(@object, parse_class)
299
+ end
300
+
301
+ public
302
+
303
+ # This method will intentionally raise a {Parse::Webhooks::ResponseError} with
304
+ # a specific message. When used inside of a registered cloud code webhook
305
+ # function or trigger, will halt processing and return the proper error response
306
+ # code back to the Parse server.
307
+ # @param msg [String] the error message to send back.
308
+ # @raise Parse::Webhooks::ResponseError
309
+ # @return [Parse::Webhooks::ResponseError] the raised exception
310
+ def error!(msg = "")
311
+ raise Parse::Webhooks::ResponseError, msg
312
+ end
313
+
314
+ # @return [Parse::Query] the Parse query for a beforeFind trigger.
315
+ def parse_query
316
+ return nil unless parse_class.present? && @query.is_a?(Hash)
317
+ Parse::Query.new parse_class, @query
318
+ end
319
+
320
+ # Returns true if this webhook was triggered by a Ruby Parse Stack request.
321
+ # This is determined by checking for the '_RB_' prefix in the request ID header.
322
+ # This flag is useful for preventing callback loops and implementing intelligent
323
+ # callback handling based on the request origin.
324
+ # @return [Boolean] true if the request originated from Ruby Parse Stack
325
+ def ruby_initiated?
326
+ @ruby_initiated ||= begin
327
+ request_id = nil
328
+
329
+ if @raw.respond_to?(:[])
330
+ # Check for headers at the top level first
331
+ request_id = @raw["x-parse-request-id"] || @raw["X-Parse-Request-Id"] ||
332
+ @raw[:x_parse_request_id] || @raw[:'X-Parse-Request-Id']
333
+
334
+ # If not found at top level, check nested headers
335
+ if request_id.nil?
336
+ headers_sym = @raw[:headers] if @raw[:headers].is_a?(Hash)
337
+ headers_str = @raw["headers"] if @raw["headers"].is_a?(Hash)
338
+
339
+ if headers_sym
340
+ request_id = headers_sym["x-parse-request-id"] || headers_sym["X-Parse-Request-Id"]
341
+ elsif headers_str
342
+ request_id = headers_str["x-parse-request-id"] || headers_str["X-Parse-Request-Id"]
343
+ end
344
+ end
345
+ end
346
+
347
+ request_id&.start_with?("_RB_") || false
348
+ end
349
+ end
350
+
351
+ # Returns true if this webhook was triggered by a client request (JavaScript, iOS, Android, etc.)
352
+ # This is the inverse of ruby_initiated? and is useful for callback logic that should
353
+ # only run for client-initiated operations.
354
+ # @return [Boolean] true if the request originated from a client (not Ruby)
355
+ def client_initiated?
356
+ !ruby_initiated?
357
+ end
358
+ end # Payload
359
+ end
360
+ end