parse-stack-next 5.7.5 → 5.8.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +856 -0
  3. data/README.md +15 -4
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +190 -14
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +318 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +290 -17
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
data/lib/parse/mongodb.rb CHANGED
@@ -4,6 +4,7 @@
4
4
  require "date"
5
5
  require "set"
6
6
  require "time"
7
+ require "erb"
7
8
  require_relative "pipeline_security"
8
9
  require_relative "clp_scope"
9
10
  require_relative "acl_scope"
@@ -1165,6 +1166,9 @@ module Parse
1165
1166
  # in {Parse::PipelineSecurity::DENIED_OPERATORS}.
1166
1167
  DENIED_OPERATORS = Parse::PipelineSecurity::DENIED_OPERATORS
1167
1168
 
1169
+ # @!visibility private
1170
+ EMPTY_COLUMN_SET = Set.new.freeze
1171
+
1168
1172
  # @!visibility private
1169
1173
  # Default BFS depth for role-graph expansion. Real-world role graphs
1170
1174
  # are 2-4 deep; 10 leaves headroom for unusual hierarchies without
@@ -1803,6 +1807,12 @@ module Parse
1803
1807
  pipeline, class_name: collection_name, enabled: rewrite_lookups,
1804
1808
  )
1805
1809
 
1810
+ # Decide, from the caller's own stages, whether a returned row's
1811
+ # `_id` is still the stored one. The `_User` self exemption below
1812
+ # trusts the output `_id`, so it is granted only when no caller
1813
+ # stage could have rewritten it; otherwise the field is stripped.
1814
+ identity_preserved = Parse::PipelineSecurity.identity_preserving?(pipeline)
1815
+
1806
1816
  # Three-layer ACL simulation on the mongo-direct path:
1807
1817
  #
1808
1818
  # 1. Top-level $match: filter the queried collection's rows by
@@ -1824,42 +1834,38 @@ module Parse
1824
1834
  # pipeline above; the injected stages reference `_rperm` but
1825
1835
  # are SDK-generated (not attacker-controlled), so no
1826
1836
  # re-validation is needed before they're handed to MongoDB.
1827
- if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
1828
- pipeline = prepend_or_fold_acl_match(pipeline, acl_stage)
1829
- end
1830
- pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution)
1831
-
1832
1837
  # Class-Level Permissions boundary check. Parse Server's REST
1833
1838
  # aggregate endpoint runs master-key-only and does NOT enforce
1834
1839
  # CLP; the mongo-direct path bypasses Parse Server entirely so
1835
- # the SDK is the only enforcement layer. Refuse the call when
1836
- # the resolved scope can't `find` on the collection. Master-
1837
- # key (resolution.master? / nil permission_strings) bypasses.
1840
+ # the SDK is the only enforcement layer. Evaluate the `find` CLP
1841
+ # with Parse Server's branch semantics: a public / user / role
1842
+ # grant permits every row, otherwise a pointerFields or
1843
+ # readUserFields grant permits only the rows whose named pointer
1844
+ # references the requesting user. Raises when the scope cannot
1845
+ # find at all. Master-key (resolution.master?) bypasses.
1838
1846
  perms_for_clp = resolution&.permission_strings
1839
- unless resolution.nil? || resolution.master?
1840
- unless Parse::CLPScope.permits?(collection_name, :find, perms_for_clp)
1841
- raise Parse::CLPScope::Denied.new(
1842
- collection_name, :find,
1843
- "CLP refuses find on '#{collection_name}' for the current scope.",
1844
- )
1845
- end
1846
- end
1847
+ pointer_fields = Parse::CLPScope.row_constraint_for!(
1848
+ collection_name, :find, resolution, client: clp_client_for(resolution),
1849
+ )
1847
1850
 
1848
- # Resolve the pointerFields constraint (if any) BEFORE running
1849
- # the query — we apply the filter post-fetch but want to fail
1850
- # loudly when the scope can't satisfy the constraint at all
1851
- # (acl_role-only / public agents have no user_id to match).
1852
- pointer_fields = nil
1853
- unless resolution.nil? || resolution.master?
1854
- pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
1855
- if pointer_fields && resolution.user_id.nil?
1856
- raise Parse::CLPScope::Denied.new(
1857
- collection_name, :find,
1858
- "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
1859
- "but the current scope has no user_id.",
1860
- )
1861
- end
1851
+ # The pointer-permission constraint is applied IN the pipeline,
1852
+ # folded into the leading ACL `$match`, so it runs before any
1853
+ # caller `$skip` / `$limit` / `$count` / `$group`. Filtering the
1854
+ # fetched rows afterwards made `count_direct` return 0 (the
1855
+ # `$count` row has no pointer column) and returned short or
1856
+ # empty pages.
1857
+ front_predicates = []
1858
+ if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
1859
+ front_predicates << (acl_stage["$match"] || acl_stage[:$match])
1860
+ end
1861
+ if pointer_fields
1862
+ front_predicates << Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id)
1862
1863
  end
1864
+ unless front_predicates.empty?
1865
+ front = front_predicates.size == 1 ? front_predicates.first : { "$and" => front_predicates }
1866
+ pipeline = prepend_or_fold_acl_match(pipeline, { "$match" => front })
1867
+ end
1868
+ pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution, class_name: collection_name)
1863
1869
 
1864
1870
  agg_opts = {}
1865
1871
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
@@ -1879,25 +1885,25 @@ module Parse
1879
1885
  results = with_query_killed_retry(collection_name) { coll.aggregate(pipeline, agg_opts).to_a }
1880
1886
  Parse::ACLScope.redact_results!(results, resolution)
1881
1887
 
1882
- # Post-fetch pointerFields filter: drop rows where none of the
1883
- # named pointer fields references the requesting user. Skipped
1884
- # for master-key and when the CLP has no pointerFields entry.
1885
- if pointer_fields
1886
- results = Parse::CLPScope.filter_by_pointer_fields(
1887
- results, pointer_fields, resolution.user_id,
1888
- )
1889
- end
1890
-
1891
- # Protected fields stripping. Resolve the field set per the
1892
- # session's claim composition and walk-delete from every
1893
- # row + embedded sub-document. Top-level $project would also
1894
- # work but doesn't reach inside `$lookup`-included sub-docs,
1895
- # so the post-walker is the defense-in-depth layer.
1888
+ # Protected fields stripping, top-level keys only (Parse Server
1889
+ # deletes `object[key]` and does not descend). The queried class's
1890
+ # set applies to each row; every `$lookup` in the executed pipeline
1891
+ # pulls rows of another class under its `as` key, and those rows get
1892
+ # THAT class's set, the same as Parse Server's include sub-query
1893
+ # does. `_User` rows that are the requesting user keep their own
1894
+ # protected fields, as on REST, but only when that identity is
1895
+ # proven: the caller's stages preserve `_id` (root rows), or the
1896
+ # join's head strip already decided ownership on the stored `_id`
1897
+ # (joined rows, see {Parse::ACLScope.rewrite_pipeline}).
1896
1898
  unless resolution.nil? || resolution.master?
1897
1899
  strip_set = Parse::CLPScope.protected_fields_for(
1898
- collection_name, perms_for_clp,
1900
+ collection_name, perms_for_clp, client: clp_client_for(resolution),
1899
1901
  )
1900
- Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
1902
+ Parse::CLPScope.redact_protected_fields!(
1903
+ results, strip_set, class_name: collection_name,
1904
+ user_id: identity_preserved ? resolution.user_id : nil,
1905
+ ) if strip_set.any?
1906
+ redact_joined_protected_fields!(results, pipeline, resolution)
1901
1907
 
1902
1908
  # Process-level floor: recursively strip Parse-internal credential
1903
1909
  # columns (_hashed_password, _session_token, _auth_data_*, _rperm,
@@ -1920,6 +1926,56 @@ module Parse
1920
1926
  raise
1921
1927
  end
1922
1928
 
1929
+ # The client whose schema decides CLP for a resolution. A user-scoped
1930
+ # client (from `become` / `session_client`) holds no master key and
1931
+ # cannot read `/schemas`, so it falls back to the default client (nil
1932
+ # here), which is what every CLP lookup used before resolutions
1933
+ # carried a client.
1934
+ # @!visibility private
1935
+ def clp_client_for(resolution)
1936
+ c = Parse::ACLScope.client_of(resolution)
1937
+ return nil if c.nil?
1938
+ return nil unless c.respond_to?(:master_key) && !c.master_key.to_s.empty?
1939
+ c
1940
+ end
1941
+
1942
+ # Strip each joined class's protectedFields from the rows a top-level
1943
+ # `$lookup` pulled in. Mirrors Parse Server's include, which fetches the
1944
+ # included objects with a sub-query on their own class and therefore
1945
+ # applies that class's protectedFields (plus the server default
1946
+ # `_User.email` protection) to them.
1947
+ # @!visibility private
1948
+ def redact_joined_protected_fields!(results, pipeline, resolution)
1949
+ return if results.nil? || results.empty? || !pipeline.is_a?(Array)
1950
+ perms = resolution.permission_strings
1951
+ return if perms.nil?
1952
+ # The self exemption is safe only when the join's sub-pipeline began
1953
+ # with the strip stage, which decided ownership on the stored `_id`.
1954
+ self_id = Parse::ACLScope.rewrites_joins?(resolution) ? resolution.user_id : nil
1955
+ pipeline.each do |stage|
1956
+ next unless stage.is_a?(Hash)
1957
+ spec = stage["$lookup"] || stage[:$lookup]
1958
+ next unless spec.is_a?(Hash)
1959
+ from = spec["from"] || spec[:from]
1960
+ as = spec["as"] || spec[:as]
1961
+ next if from.to_s.empty? || as.to_s.empty?
1962
+ set = Parse::CLPScope.protected_fields_for(from.to_s, perms, client: clp_client_for(resolution))
1963
+ next if set.empty?
1964
+ path = as.to_s.split(".")
1965
+ results.each do |row|
1966
+ value = path.reduce(row) { |node, seg| node.is_a?(Hash) ? (node[seg] || node[seg.to_sym]) : nil }
1967
+ docs = case value
1968
+ when Hash then [value]
1969
+ when Array then value.select { |v| v.is_a?(Hash) }
1970
+ else []
1971
+ end
1972
+ next if docs.empty?
1973
+ Parse::CLPScope.redact_protected_fields!(docs, set, class_name: from.to_s, user_id: self_id)
1974
+ end
1975
+ end
1976
+ nil
1977
+ end
1978
+
1923
1979
  # Inject the scoped ACL `$match` at the front of a pipeline — UNLESS
1924
1980
  # the first stage is `$geoNear`, which MongoDB requires to be
1925
1981
  # pipeline stage 0. In that case fold the ACL predicate into
@@ -2279,6 +2335,8 @@ module Parse
2279
2335
  return nil unless doc.is_a?(Hash)
2280
2336
 
2281
2337
  result = {}
2338
+ included = {}
2339
+ file_fields = file_columns_for(class_name)
2282
2340
 
2283
2341
  doc.each do |key, value|
2284
2342
  key_str = key.to_s
@@ -2306,18 +2364,10 @@ module Parse
2306
2364
  # Convert MongoDB ACL format (r/w) to Parse format (read/write)
2307
2365
  result["ACL"] = convert_acl_to_parse(value)
2308
2366
  when /^_included_(.+)$/
2309
- # Included/resolved pointer field from $lookup - convert embedded document
2310
- # This handles eager loading: _included_artist -> artist (as full object)
2311
- field_name = $1
2312
- if value.is_a?(Hash)
2313
- # Recursively convert the embedded document to Parse format
2314
- result[field_name] = convert_document_to_parse(value)
2315
- elsif value.nil?
2316
- # Preserve nil for unresolved optional relationships
2317
- result[field_name] = nil
2318
- else
2319
- result[field_name] = value
2320
- end
2367
+ # Included/resolved pointer field from $lookup. Resolved after
2368
+ # the loop, once the sibling `_p_<field>` pointer (which names
2369
+ # the included object's class) has been seen.
2370
+ included[$1] = value
2321
2371
  when /^_include_id_/
2322
2372
  # Skip temporary lookup ID fields (used internally for $lookup)
2323
2373
  next
@@ -2330,8 +2380,33 @@ module Parse
2330
2380
  # Skip other internal fields starting with underscore
2331
2381
  next
2332
2382
  else
2333
- # Regular fields - recursively convert nested documents
2334
- result[key_str] = convert_value_to_parse(value)
2383
+ # Regular fields - recursively convert nested documents. Parse
2384
+ # Server stores a File column as the bare file name; REST returns
2385
+ # it as a `File` object, so rebuild that shape.
2386
+ result[key_str] = if value.is_a?(String) && file_fields.include?(key_str)
2387
+ file_to_parse(value)
2388
+ else
2389
+ convert_value_to_parse(value)
2390
+ end
2391
+ end
2392
+ end
2393
+
2394
+ included.each do |field_name, value|
2395
+ if value.is_a?(Hash)
2396
+ # REST returns an included object as a full `Object` carrying
2397
+ # its `__type` and `className`. The class comes from the stored
2398
+ # pointer (`Class$objectId`); without it the object decoded as nil.
2399
+ pointer = doc["_p_#{field_name}"] || doc[:"_p_#{field_name}"]
2400
+ target = pointer.is_a?(String) && pointer.include?("$") ? pointer.split("$", 2).first : nil
2401
+ converted = convert_document_to_parse(value, target)
2402
+ converted["__type"] = Parse::Model::TYPE_OBJECT if target
2403
+ result[field_name] = converted
2404
+ elsif value.nil?
2405
+ # The include resolved to nothing: a dangling pointer, or a row
2406
+ # the scope cannot read. REST omits the field in that case.
2407
+ result.delete(field_name)
2408
+ else
2409
+ result[field_name] = value
2335
2410
  end
2336
2411
  end
2337
2412
 
@@ -2341,6 +2416,41 @@ module Parse
2341
2416
  result
2342
2417
  end
2343
2418
 
2419
+ # Mongo column names of the `:file` properties declared on the model
2420
+ # registered for `class_name`.
2421
+ # @return [Set<String>]
2422
+ # @!visibility private
2423
+ def file_columns_for(class_name)
2424
+ return EMPTY_COLUMN_SET if class_name.nil?
2425
+ klass = Parse::Model.find_class(class_name.to_s) rescue nil
2426
+ return EMPTY_COLUMN_SET unless klass.respond_to?(:fields)
2427
+ map = klass.respond_to?(:field_map) ? klass.field_map : {}
2428
+ klass.fields.each_with_object(Set.new) do |(local, type), set|
2429
+ next unless type == :file
2430
+ set << (map[local] || local).to_s
2431
+ end
2432
+ rescue StandardError
2433
+ EMPTY_COLUMN_SET
2434
+ end
2435
+
2436
+ # Parse Server's REST shape for a stored file name. The URL follows
2437
+ # Parse Server's default files route (`<serverURL>/files/<appId>/<name>`),
2438
+ # used by the GridFS and filesystem adapters and by adapters without
2439
+ # direct access. An adapter with direct access (for example S3 with
2440
+ # `directAccess: true`) serves a different URL; there REST is the
2441
+ # authoritative source.
2442
+ # @!visibility private
2443
+ def file_to_parse(name)
2444
+ out = { "__type" => Parse::Model::TYPE_FILE, "name" => name }
2445
+ client = Parse::Client.client? ? (Parse::Client.client rescue nil) : nil
2446
+ if client && client.respond_to?(:server_url) && client.respond_to?(:application_id) &&
2447
+ !client.server_url.to_s.empty? && !client.application_id.to_s.empty?
2448
+ base = client.server_url.to_s.chomp("/")
2449
+ out["url"] = "#{base}/files/#{client.application_id}/#{ERB::Util.url_encode(name)}"
2450
+ end
2451
+ out
2452
+ end
2453
+
2344
2454
  # Convert multiple MongoDB documents to Parse format
2345
2455
  # @param docs [Array<Hash>] the MongoDB documents
2346
2456
  # @param class_name [String] the Parse class name