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,544 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ # Class-Level Permissions (CLP) for Parse Server classes.
6
+ #
7
+ # CLPs control access to a class at the schema level, determining who can
8
+ # perform operations on the class and which fields are visible to different
9
+ # users/roles.
10
+ #
11
+ # ## Protected Fields Behavior
12
+ #
13
+ # When a user matches multiple patterns (e.g., public "*", "authenticated", and a role),
14
+ # the protected fields are the **intersection** of all matching patterns. This means
15
+ # a field is only hidden if it's protected by ALL patterns that apply to the user.
16
+ #
17
+ # For example:
18
+ # - `*` protects ["owner", "test"]
19
+ # - `role:Admin` protects ["owner"]
20
+ # - A user with Admin role matches both patterns
21
+ # - Result: only "owner" is hidden (intersection), "test" is visible
22
+ #
23
+ # An empty array `[]` for a pattern means "no fields protected" (user sees everything).
24
+ # If any matching pattern has an empty array, the intersection will also be empty.
25
+ #
26
+ # @example Defining CLPs in a model
27
+ # class Song < Parse::Object
28
+ # property :title, :string
29
+ # property :artist, :string
30
+ # property :internal_notes, :string # Should be hidden from regular users
31
+ #
32
+ # # Set class-level permissions
33
+ # set_clp :find, public: true
34
+ # set_clp :get, public: true
35
+ # set_clp :create, public: false, roles: ["Admin", "Editor"]
36
+ # set_clp :update, public: false, roles: ["Admin", "Editor"]
37
+ # set_clp :delete, public: false, roles: ["Admin"]
38
+ #
39
+ # # Protect fields from certain users
40
+ # protect_fields "*", [:internal_notes, :secret_data] # Hidden from everyone
41
+ # protect_fields "role:Admin", [] # Admins can see everything
42
+ # end
43
+ #
44
+ # @example Using userField for owner-based access
45
+ # class Document < Parse::Object
46
+ # property :content, :string
47
+ # property :secret, :string
48
+ # belongs_to :owner, as: :user
49
+ #
50
+ # # Hide secret from everyone
51
+ # protect_fields "*", [:secret, :owner]
52
+ # # But owners can see their own document's secret
53
+ # protect_fields "userField:owner", []
54
+ # end
55
+ #
56
+ # @example Fetching CLPs from server
57
+ # clp = Song.fetch_clp
58
+ # clp.find_allowed?("role:Admin") # => true
59
+ # clp.protected_fields_for("*") # => ["internal_notes", "secret_data"]
60
+ #
61
+ # @see https://docs.parseplatform.org/rest/guide/#class-level-permissions
62
+ class CLP
63
+ # Valid CLP operation keys for permission-based access
64
+ OPERATIONS = %i[find get count create update delete addField].freeze
65
+
66
+ # Pointer-permission keys (users in these fields get read/write access)
67
+ POINTER_PERMISSIONS = %i[readUserFields writeUserFields].freeze
68
+
69
+ # All valid CLP keys
70
+ ALL_KEYS = (OPERATIONS + POINTER_PERMISSIONS + [:protectedFields]).freeze
71
+
72
+ # @return [Hash] the raw CLP hash
73
+ attr_reader :permissions
74
+
75
+ # Create a new CLP instance.
76
+ # @param data [Hash] optional initial CLP data from Parse Server
77
+ def initialize(data = nil)
78
+ @permissions = {}
79
+ @protected_fields = {}
80
+ parse_data(data) if data.is_a?(Hash)
81
+ end
82
+
83
+ # Parse CLP data from Parse Server format.
84
+ # @param data [Hash] CLP hash from server
85
+ def parse_data(data)
86
+ data.each do |key, value|
87
+ key_sym = key.to_sym
88
+ if key_sym == :protectedFields
89
+ @protected_fields = value.transform_keys(&:to_s)
90
+ elsif OPERATIONS.include?(key_sym)
91
+ @permissions[key_sym] = value.transform_keys(&:to_s)
92
+ elsif POINTER_PERMISSIONS.include?(key_sym)
93
+ # readUserFields and writeUserFields are arrays of field names
94
+ @permissions[key_sym] = Array(value)
95
+ else
96
+ # Store any other keys
97
+ @permissions[key_sym] = value
98
+ end
99
+ end
100
+ end
101
+
102
+ # Set pointer-permission fields for read access.
103
+ # Users pointed to by these fields can read the object.
104
+ # @param fields [Array<String, Symbol>] pointer field names
105
+ # @return [self]
106
+ # @example
107
+ # clp.set_read_user_fields(:owner, :collaborators)
108
+ def set_read_user_fields(*fields)
109
+ @permissions[:readUserFields] = fields.flatten.map(&:to_s)
110
+ self
111
+ end
112
+
113
+ # Set pointer-permission fields for write access.
114
+ # Users pointed to by these fields can write to the object.
115
+ # @param fields [Array<String, Symbol>] pointer field names
116
+ # @return [self]
117
+ # @example
118
+ # clp.set_write_user_fields(:owner)
119
+ def set_write_user_fields(*fields)
120
+ @permissions[:writeUserFields] = fields.flatten.map(&:to_s)
121
+ self
122
+ end
123
+
124
+ # Get the read user fields.
125
+ # @return [Array<String>] pointer field names for read access
126
+ def read_user_fields
127
+ @permissions[:readUserFields] || []
128
+ end
129
+
130
+ # Get the write user fields.
131
+ # @return [Array<String>] pointer field names for write access
132
+ def write_user_fields
133
+ @permissions[:writeUserFields] || []
134
+ end
135
+
136
+ # Set permissions for a specific operation.
137
+ # @param operation [Symbol] one of :find, :get, :count, :create, :update, :delete, :addField
138
+ # @param public_access [Boolean, nil] whether public access is allowed
139
+ # @param roles [Array<String>] role names that have access
140
+ # @param users [Array<String>] user objectIds that have access
141
+ # @param pointer_fields [Array<String>] pointer field names for userField access
142
+ # @param requires_authentication [Boolean] whether authentication is required
143
+ # @return [self]
144
+ def set_permission(operation, public_access: nil, roles: [], users: [], pointer_fields: [], requires_authentication: false)
145
+ operation = operation.to_sym
146
+ raise ArgumentError, "Invalid operation: #{operation}" unless OPERATIONS.include?(operation)
147
+
148
+ perm = {}
149
+
150
+ # Handle public access
151
+ # Note: Parse Server only accepts 'true' values for CLP permissions.
152
+ # Setting public: false means "don't grant public access" which is
153
+ # achieved by simply not including the "*" key (absence = no access).
154
+ perm["*"] = true if public_access == true
155
+
156
+ # Handle requiresAuthentication
157
+ perm["requiresAuthentication"] = true if requires_authentication
158
+
159
+ # Handle roles
160
+ Array(roles).each do |role|
161
+ role_key = role.start_with?("role:") ? role : "role:#{role}"
162
+ perm[role_key] = true
163
+ end
164
+
165
+ # Handle users
166
+ Array(users).each do |user_id|
167
+ perm[user_id] = true
168
+ end
169
+
170
+ # Handle pointer fields (userField:fieldName pattern)
171
+ Array(pointer_fields).each do |field|
172
+ field_key = field.start_with?("pointerFields") ? field : "pointerFields"
173
+ perm[field_key] ||= []
174
+ perm[field_key] << field unless field.start_with?("pointerFields")
175
+ end
176
+
177
+ @permissions[operation] = perm
178
+ self
179
+ end
180
+
181
+ # Set protected fields for a specific user/role pattern.
182
+ # @param pattern [String] the pattern ("*", "role:RoleName", "userField:fieldName", or user objectId)
183
+ # @param fields [Array<String, Symbol>] field names to protect (hide) from this pattern
184
+ # @return [self]
185
+ # @example
186
+ # clp.set_protected_fields("*", [:email, :phone]) # Hide from everyone
187
+ # clp.set_protected_fields("role:Admin", []) # Admins see everything
188
+ # clp.set_protected_fields("userField:owner", []) # Owners see everything
189
+ def set_protected_fields(pattern, fields)
190
+ pattern = "*" if pattern.to_sym == :public rescue pattern
191
+ @protected_fields[pattern.to_s] = Array(fields).map(&:to_s)
192
+ self
193
+ end
194
+
195
+ # Get protected fields for a specific pattern.
196
+ # @param pattern [String] the pattern to look up
197
+ # @return [Array<String>] the protected field names
198
+ def protected_fields_for(pattern)
199
+ @protected_fields[pattern.to_s] || []
200
+ end
201
+
202
+ # Get all protected fields configuration.
203
+ # @return [Hash] pattern => [fields] mapping (deep copy)
204
+ def protected_fields
205
+ @protected_fields.transform_values(&:dup)
206
+ end
207
+
208
+ # Check if a specific pattern has access to an operation.
209
+ # @param operation [Symbol] the operation to check
210
+ # @param pattern [String] the pattern ("*", "role:RoleName", user objectId)
211
+ # @return [Boolean]
212
+ def allowed?(operation, pattern)
213
+ perm = @permissions[operation.to_sym]
214
+ return false unless perm
215
+
216
+ # Check direct access
217
+ return true if perm[pattern.to_s] == true
218
+ return true if perm["*"] == true
219
+
220
+ false
221
+ end
222
+
223
+ # Check if public access is allowed for an operation.
224
+ # @param operation [Symbol] the operation to check
225
+ # @return [Boolean]
226
+ def public_access?(operation)
227
+ allowed?(operation, "*")
228
+ end
229
+
230
+ # Check if a role has access to an operation.
231
+ # @param operation [Symbol] the operation to check
232
+ # @param role_name [String] the role name (with or without "role:" prefix)
233
+ # @return [Boolean]
234
+ def role_allowed?(operation, role_name)
235
+ role_key = role_name.start_with?("role:") ? role_name : "role:#{role_name}"
236
+ allowed?(operation, role_key)
237
+ end
238
+
239
+ # Convenience methods for checking specific operations
240
+ %i[find get count create update delete addField].each do |op|
241
+ define_method(:"#{op}_allowed?") do |pattern = "*"|
242
+ allowed?(op, pattern)
243
+ end
244
+ end
245
+
246
+ # Check if authentication is required for an operation.
247
+ # @param operation [Symbol] the operation to check
248
+ # @return [Boolean]
249
+ def requires_authentication?(operation)
250
+ perm = @permissions[operation.to_sym]
251
+ return false unless perm
252
+ perm["requiresAuthentication"] == true
253
+ end
254
+
255
+ # Filter fields from a hash based on protected fields for a user/role.
256
+ # This is the core method for filtering webhook responses.
257
+ #
258
+ # Uses **intersection** logic: when a user matches multiple patterns,
259
+ # only fields that are protected by ALL matching patterns are hidden.
260
+ # This matches Parse Server's behavior.
261
+ #
262
+ # @param data [Hash] the data hash to filter
263
+ # @param user [Parse::User, String, nil] the user making the request (or user ID)
264
+ # @param roles [Array<String>] role names the user belongs to
265
+ # @param authenticated [Boolean] whether the user is authenticated (affects "authenticated" pattern)
266
+ # @return [Hash] filtered data with protected fields removed
267
+ #
268
+ # @example Filtering data for a regular user
269
+ # filtered = clp.filter_fields(song_data, user: current_user, roles: ["Member"])
270
+ #
271
+ # @example Filtering data in a webhook
272
+ # # In your webhook handler:
273
+ # clp = Song.fetch_clp
274
+ # filtered_data = clp.filter_fields(
275
+ # response_data,
276
+ # user: request_user,
277
+ # roles: user_roles
278
+ # )
279
+ #
280
+ # @example Filtering with authentication check
281
+ # # Authenticated users may have different visibility
282
+ # clp.filter_fields(data, user: user, roles: roles, authenticated: true)
283
+ def filter_fields(data, user: nil, roles: [], authenticated: nil)
284
+ return data if data.nil?
285
+ return data.map { |item| filter_fields(item, user: user, roles: roles, authenticated: authenticated) } if data.is_a?(Array)
286
+ return data unless data.is_a?(Hash)
287
+
288
+ # Auto-detect authentication if not specified
289
+ authenticated = user.present? if authenticated.nil?
290
+
291
+ # Build list of patterns that apply to this user/context
292
+ applicable_patterns = build_applicable_patterns(user, roles, authenticated, data)
293
+
294
+ # Determine which fields to hide using intersection logic
295
+ fields_to_hide = determine_fields_to_hide(applicable_patterns)
296
+
297
+ # Return filtered data
298
+ data.reject { |key, _| fields_to_hide.include?(key.to_s) }
299
+ end
300
+
301
+ # The default permission to use for operations not explicitly set.
302
+ # When set, `as_json` will include this for all undefined operations.
303
+ # @return [Hash, nil] the default permission hash (e.g., { "*" => true })
304
+ attr_accessor :default_permission
305
+
306
+ # Default public permission used as fallback when include_defaults is true
307
+ # but no explicit default_permission has been set.
308
+ DEFAULT_PUBLIC_PERMISSION = { "*" => true }.freeze
309
+
310
+ # Convert to Parse Server CLP format.
311
+ #
312
+ # IMPORTANT: Parse Server interprets missing operations as {} (no access).
313
+ # If you have protectedFields but no operations defined, the class becomes
314
+ # effectively master-key-only. Use `set_default_permission` or `include_defaults`
315
+ # to ensure all operations are included.
316
+ #
317
+ # @param include_defaults [Boolean] whether to include default permissions
318
+ # for operations that haven't been explicitly set. When true, uses
319
+ # @default_permission if set, otherwise falls back to public access.
320
+ # @return [Hash] the CLP hash suitable for schema updates
321
+ def as_json(include_defaults: nil)
322
+ result = {}
323
+
324
+ # Determine if we should include defaults
325
+ # Auto-enable if any CLP settings exist and no explicit choice made
326
+ should_include_defaults = if include_defaults.nil?
327
+ present? && @default_permission
328
+ else
329
+ include_defaults
330
+ end
331
+
332
+ # Determine the default permission to use
333
+ # Use explicit default_permission if set, otherwise fall back to public
334
+ effective_default = @default_permission || DEFAULT_PUBLIC_PERMISSION
335
+
336
+ # Add operation permissions
337
+ OPERATIONS.each do |op|
338
+ if @permissions[op]
339
+ result[op.to_s] = @permissions[op]
340
+ elsif should_include_defaults
341
+ result[op.to_s] = effective_default.dup
342
+ end
343
+ end
344
+
345
+ # Add pointer permissions (readUserFields, writeUserFields)
346
+ POINTER_PERMISSIONS.each do |perm|
347
+ result[perm.to_s] = @permissions[perm] if @permissions[perm]&.any?
348
+ end
349
+
350
+ # Add protected fields
351
+ result["protectedFields"] = @protected_fields unless @protected_fields.empty?
352
+
353
+ result
354
+ end
355
+
356
+ # Set the default permission for operations not explicitly configured.
357
+ # This ensures that when CLPs are pushed to Parse Server, all operations
358
+ # have explicit permissions (avoiding the implicit {} = no access behavior).
359
+ #
360
+ # @param public_access [Boolean] whether public access is allowed
361
+ # @param requires_authentication [Boolean] whether authentication is required
362
+ # @param roles [Array<String>] role names that have access
363
+ # @return [self]
364
+ # @example
365
+ # clp.set_default_permission(public_access: true) # Default to public
366
+ # clp.set_default_permission(requires_authentication: true) # Default to auth required
367
+ def set_default_permission(public_access: nil, requires_authentication: false, roles: [])
368
+ perm = {}
369
+ perm["*"] = true if public_access == true
370
+ perm["requiresAuthentication"] = true if requires_authentication
371
+ Array(roles).each { |role| perm["role:#{role}"] = true }
372
+ @default_permission = perm.empty? ? nil : perm
373
+ self
374
+ end
375
+
376
+ alias_method :to_h, :as_json
377
+
378
+ # Check if there are any CLP settings.
379
+ # @return [Boolean]
380
+ def present?
381
+ @permissions.any? || @protected_fields.any?
382
+ end
383
+
384
+ # Check if this CLP is empty.
385
+ # @return [Boolean]
386
+ def empty?
387
+ !present?
388
+ end
389
+
390
+ # Merge another CLP into this one (non-destructive).
391
+ # @param other [CLP, Hash] the CLP to merge
392
+ # @return [CLP] a new merged CLP
393
+ def merge(other)
394
+ other_data = other.is_a?(CLP) ? other.as_json : other
395
+ new_clp = CLP.new(as_json)
396
+ new_clp.parse_data(other_data)
397
+ new_clp
398
+ end
399
+
400
+ # Merge another CLP into this one (destructive).
401
+ # @param other [CLP, Hash] the CLP to merge
402
+ # @return [self]
403
+ def merge!(other)
404
+ other_data = other.is_a?(CLP) ? other.as_json : other
405
+ parse_data(other_data)
406
+ self
407
+ end
408
+
409
+ # Create a deep copy of this CLP.
410
+ # @return [CLP]
411
+ def dup
412
+ CLP.new(as_json)
413
+ end
414
+
415
+ # Equality check.
416
+ # @param other [CLP, Hash] the other CLP to compare
417
+ # @return [Boolean]
418
+ def ==(other)
419
+ return false unless other.is_a?(CLP) || other.is_a?(Hash)
420
+ as_json == (other.is_a?(CLP) ? other.as_json : other)
421
+ end
422
+
423
+ def inspect
424
+ "#<Parse::CLP #{as_json.inspect}>"
425
+ end
426
+
427
+ private
428
+
429
+ # Build list of patterns that apply to a given user context.
430
+ # All matching patterns will be used for intersection logic.
431
+ #
432
+ # @param user [Parse::User, String, nil] the user or user ID
433
+ # @param roles [Array<String>] role names
434
+ # @param authenticated [Boolean] whether user is authenticated
435
+ # @param data [Hash] the data being filtered (for userField checks)
436
+ # @return [Array<String>] all applicable patterns
437
+ def build_applicable_patterns(user, roles, authenticated, data)
438
+ patterns = []
439
+ user_id = extract_user_id(user)
440
+
441
+ # Check userField patterns (owner-based access)
442
+ @protected_fields.keys.each do |pattern|
443
+ next unless pattern.start_with?("userField:")
444
+
445
+ field_name = pattern.sub("userField:", "")
446
+ next unless data.key?(field_name) || data.key?(field_name.to_sym)
447
+
448
+ # Get the field value (could be string key or symbol key)
449
+ field_value = data[field_name] || data[field_name.to_sym]
450
+
451
+ if user_id && user_matches_field?(user_id, field_value)
452
+ patterns << pattern
453
+ end
454
+ end
455
+
456
+ # Add role patterns for all roles the user belongs to
457
+ Array(roles).each do |role|
458
+ role_pattern = role.start_with?("role:") ? role : "role:#{role}"
459
+ patterns << role_pattern if @protected_fields.key?(role_pattern)
460
+ end
461
+
462
+ # Add user-specific pattern if configured
463
+ if user_id && @protected_fields.key?(user_id)
464
+ patterns << user_id
465
+ end
466
+
467
+ # Add "authenticated" pattern if user is authenticated and pattern exists
468
+ if authenticated && @protected_fields.key?("authenticated")
469
+ patterns << "authenticated"
470
+ end
471
+
472
+ # Public pattern "*" always applies (for everyone)
473
+ patterns << "*" if @protected_fields.key?("*")
474
+
475
+ patterns
476
+ end
477
+
478
+ # Extract user ID from various user representations.
479
+ # @param user [Parse::User, String, Hash, nil] user object, ID, or pointer hash
480
+ # @return [String, nil] the user ID or nil
481
+ def extract_user_id(user)
482
+ return nil if user.nil?
483
+ return user if user.is_a?(String)
484
+ return user["objectId"] if user.is_a?(Hash) && user["objectId"]
485
+ return user[:objectId] if user.is_a?(Hash) && user[:objectId]
486
+ return user.id if user.respond_to?(:id)
487
+ nil
488
+ end
489
+
490
+ # Check if a user ID matches a field value (pointer or array of pointers).
491
+ # @param user_id [String] the user ID to check
492
+ # @param field_value [Hash, Array, String, nil] the field value
493
+ # @return [Boolean] true if the user matches
494
+ def user_matches_field?(user_id, field_value)
495
+ return false if field_value.nil? || user_id.nil?
496
+
497
+ # Handle array of pointers (e.g., owners: [user1, user2])
498
+ if field_value.is_a?(Array)
499
+ return field_value.any? { |item| user_matches_field?(user_id, item) }
500
+ end
501
+
502
+ # Handle pointer hash (e.g., owner: { __type: "Pointer", objectId: "xxx" })
503
+ if field_value.is_a?(Hash)
504
+ return field_value["objectId"] == user_id || field_value[:objectId] == user_id
505
+ end
506
+
507
+ # Handle direct ID string
508
+ field_value.to_s == user_id
509
+ end
510
+
511
+ # Determine which fields should be hidden based on applicable patterns.
512
+ #
513
+ # Uses **intersection** logic: a field is hidden only if it's protected
514
+ # by ALL matching patterns. This matches Parse Server behavior.
515
+ #
516
+ # An empty array `[]` for any matching pattern means "no fields protected"
517
+ # for that pattern, which clears protection (intersection with empty = empty).
518
+ #
519
+ # @param patterns [Array<String>] all applicable patterns
520
+ # @return [Set<String>] field names to hide
521
+ def determine_fields_to_hide(patterns)
522
+ # If no patterns match, no fields are hidden
523
+ return Set.new if patterns.empty?
524
+
525
+ # Get protected fields for each matching pattern
526
+ field_sets = patterns.map do |pattern|
527
+ fields = @protected_fields[pattern]
528
+ # Convert to Set for intersection operations
529
+ # Empty array means "no protection" -> empty set
530
+ fields.nil? ? nil : Set.new(fields)
531
+ end.compact
532
+
533
+ # If any pattern has no configuration, ignore it
534
+ return Set.new if field_sets.empty?
535
+
536
+ # If any pattern explicitly allows all fields (empty array),
537
+ # then the intersection is empty (no fields hidden)
538
+ return Set.new if field_sets.any?(&:empty?)
539
+
540
+ # Intersect all field sets - only fields protected by ALL patterns are hidden
541
+ field_sets.reduce { |result, fields| result & fields }
542
+ end
543
+ end
544
+ end