parse-stack-next 5.7.6 → 5.8.1

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  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 +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
data/lib/parse/agent.rb CHANGED
@@ -23,6 +23,8 @@ require_relative "agent/rate_limiter"
23
23
  require_relative "agent/cancellation_token"
24
24
  require_relative "agent/log_levels"
25
25
  require_relative "agent/approval_gate"
26
+ require_relative "agent/field_policy"
27
+ require_relative "agent/field_names"
26
28
  require_relative "agent/prompt_hardening"
27
29
  require_relative "agent/describe"
28
30
 
@@ -592,8 +594,10 @@ module Parse
592
594
  # Use provided port, or configured port, or default
593
595
  port ||= Parse.mcp_server_port || 3001
594
596
 
595
- @mcp_enabled = true
597
+ # Load the server before flagging MCP as enabled, so a failed load
598
+ # does not leave mcp_enabled? reporting true.
596
599
  require_relative "agent/mcp_server"
600
+ @mcp_enabled = true
597
601
  MCPServer.default_port = port
598
602
 
599
603
  # Pass remote API config if available
@@ -1031,11 +1035,35 @@ module Parse
1031
1035
  # `progress_callback` / `cancellation_token` are threaded. An
1032
1036
  # embedder on the non-MCP path may assign any object responding to
1033
1037
  # `#review`.
1038
+ #
1039
+ # A sub-agent built with `parent:` and no gate of its own uses its
1040
+ # parent's gate, read at call time: the dispatcher swaps the
1041
+ # parent's gate per request, so a copy taken at construction would
1042
+ # go stale. Without this a sub-agent fell back to {NullGate} and
1043
+ # ran tools in `require_approval_for` tiers with no approval.
1034
1044
  def approval_gate
1035
- @approval_gate ||= Parse::Agent::NullGate.new
1045
+ return @approval_gate if @approval_gate
1046
+ return @approval_parent.approval_gate if @approval_parent
1047
+ @approval_gate_defaulted = true
1048
+ @approval_gate = Parse::Agent::NullGate.new
1036
1049
  end
1037
1050
 
1038
- attr_writer :approval_gate
1051
+ # Assign this agent's own approval gate. Assigning nil removes it, so
1052
+ # a sub-agent goes back to using its parent's gate.
1053
+ def approval_gate=(gate)
1054
+ @approval_gate_defaulted = false
1055
+ @approval_gate = gate
1056
+ end
1057
+
1058
+ # @return [Parse::Agent::ApprovalGate, nil] the gate assigned on this
1059
+ # agent itself, or nil when it uses its parent's (or the default).
1060
+ # The MCP dispatcher snapshots and restores this rather than
1061
+ # {#approval_gate}, so restoring never pins a parent's gate.
1062
+ # @api private
1063
+ def approval_gate_override
1064
+ gate = @approval_gate
1065
+ gate.is_a?(Parse::Agent::NullGate) && @approval_gate_defaulted ? nil : gate
1066
+ end
1039
1067
 
1040
1068
  # @return [Boolean] true if the active cancellation token has been
1041
1069
  # tripped; false otherwise. Returns false when no token is
@@ -1559,6 +1587,7 @@ module Parse
1559
1587
  max_log_size: DEFAULT_MAX_LOG_SIZE,
1560
1588
  system_prompt: nil, system_prompt_suffix: nil, pricing: nil,
1561
1589
  tools: nil, methods: nil, classes: nil, filters: nil,
1590
+ fields: nil, field_names: nil,
1562
1591
  parent: nil, recursion_depth: nil,
1563
1592
  strict_tool_filter: nil, strict_class_filter: nil,
1564
1593
  master_atlas: nil,
@@ -1731,8 +1760,10 @@ module Parse
1731
1760
  #
1732
1761
  # * nil — inherit from parent (the common case; the
1733
1762
  # child wants whatever the parent had).
1734
- # * true — explicit opt-in (caller wants faceted_search
1735
- # authority regardless of parent).
1763
+ # * true: keep faceted_search authority. Allowed only when
1764
+ # the parent has it: a child cannot turn on
1765
+ # master-key faceting under a scoped parent, which
1766
+ # would return every row's buckets and results.
1736
1767
  # * false — explicit opt-OUT: the sub-agent should DROP
1737
1768
  # faceted_search authority even if the parent
1738
1769
  # had it. Previously `false` was the default
@@ -1748,6 +1779,13 @@ module Parse
1748
1779
  # per-row ACL via Parse::ACLScope's `_rperm` match and do
1749
1780
  # NOT consult master_atlas.
1750
1781
  master_atlas = parent.master_atlas if master_atlas.nil?
1782
+ if master_atlas == true && !parent.master_atlas
1783
+ raise ArgumentError,
1784
+ "sub-agent master_atlas: true exceeds parent's master_atlas: false. " \
1785
+ "A sub-agent cannot turn on master-key Atlas faceting its parent " \
1786
+ "was not given. Omit master_atlas to inherit the parent's setting, " \
1787
+ "or pass master_atlas: false."
1788
+ end
1751
1789
 
1752
1790
  # Inherit cooperative cancellation surface. Without this, a
1753
1791
  # delegating tool that constructs a sub-agent and drives it
@@ -1759,6 +1797,9 @@ module Parse
1759
1797
  @cancellation_token = parent.cancellation_token
1760
1798
  @progress_callback = parent.progress_callback
1761
1799
  @log_callback = parent.log_callback
1800
+ # Approval gate: delegate to the parent's current gate (see
1801
+ # {#approval_gate}).
1802
+ @approval_parent = parent
1762
1803
 
1763
1804
  # Clamp the sub-agent's permission tier at the parent's. The
1764
1805
  # default :readonly is always ≤ any parent tier, so this fires
@@ -2033,9 +2074,79 @@ module Parse
2033
2074
  @method_filter_only, @method_filter_except = normalize_method_filter(methods)
2034
2075
  @class_filter_only, @class_filter_except = normalize_class_filter(classes)
2035
2076
  @filters = normalize_query_filters(filters)
2077
+ # Per-agent field narrowing (see Parse::Agent::FieldPolicy). Stored as
2078
+ # layers so a sub-agent's policy INTERSECTS its parent's: every layer
2079
+ # that names a class must permit a field for the agent to see it.
2080
+ own_field_policy = normalize_field_policy(fields)
2081
+ @field_policy_layers = (parent ? parent.field_policy_layers : []) +
2082
+ (own_field_policy ? [own_field_policy] : [])
2083
+ @field_policy_layers.freeze
2084
+ # Data-field naming mode (see Parse::Agent::FieldNames). A sub-agent
2085
+ # inherits its parent's mode unless it sets one; naming never affects
2086
+ # the access restrictions inherited above.
2087
+ @field_names_mode = if field_names.nil?
2088
+ parent ? parent.field_names_mode : :default
2089
+ else
2090
+ Parse::AggregationResult.normalize_field_names!(field_names)
2091
+ end
2092
+
2093
+ # Sub-agent `tools:` inheritance: narrow only, same rule as `classes:`
2094
+ # below. Intersect onlies, union excepts. A child `only:` that leaves
2095
+ # nothing once the parent's allowlist and excepts apply raises; an
2096
+ # explicitly empty `only: []` is the strictest narrowing and is kept.
2097
+ if parent
2098
+ parent_tools_only = parent.instance_variable_get(:@tool_filter_only)
2099
+ parent_tools_except = parent.instance_variable_get(:@tool_filter_except)
2100
+ requested_tools = @tool_filter_only
2101
+ if parent_tools_only && @tool_filter_only
2102
+ @tool_filter_only = (@tool_filter_only & parent_tools_only).freeze
2103
+ elsif parent_tools_only
2104
+ @tool_filter_only = parent_tools_only
2105
+ end
2106
+ if parent_tools_except
2107
+ @tool_filter_except = (@tool_filter_except ? (@tool_filter_except | parent_tools_except) : parent_tools_except).freeze
2108
+ end
2109
+ if requested_tools && !requested_tools.empty? &&
2110
+ (parent_tools_only || parent_tools_except)
2111
+ reachable = @tool_filter_only - (@tool_filter_except || Set.new)
2112
+ if reachable.empty?
2113
+ raise ArgumentError,
2114
+ "sub-agent tools: { only: } would have no overlap with the parent's " \
2115
+ "tools: filter (parent only: #{parent_tools_only&.to_a&.sort.inspect}, " \
2116
+ "except: #{parent_tools_except&.to_a&.sort.inspect}; the child requested " \
2117
+ "#{requested_tools.to_a.sort.inspect}). A sub-agent cannot enable tools its " \
2118
+ "parent was not given. Pass a subset of the parent's tools, or omit tools: " \
2119
+ "to inherit the parent's filter."
2120
+ end
2121
+ end
2122
+ end
2036
2123
 
2037
- # Sub-agent class-filter inheritance. Unlike `tools:` (which overrides
2038
- # outright), `classes:` clamps to the parent's effective set so a
2124
+ # Sub-agent `methods:` inheritance. Entries can be bare (`:archive`) or
2125
+ # qualified (`"Post.archive"`), so plain set intersection would get the
2126
+ # matching wrong. Instead the parent's filters are kept as layers and
2127
+ # {#method_filtered?} refuses a call any layer refuses. A child
2128
+ # `only:` that no parent layer could permit raises; an explicitly
2129
+ # empty `only: []` is kept.
2130
+ @method_filter_layers = (parent ? parent.method_filter_layers : []).dup
2131
+ if parent && @method_filter_only && !@method_filter_only.empty? && !@method_filter_layers.empty?
2132
+ reachable = @method_filter_only.any? do |entry|
2133
+ @method_filter_layers.all? { |layer_only, layer_except| method_entry_reachable?(entry, layer_only, layer_except) }
2134
+ end
2135
+ unless reachable
2136
+ raise ArgumentError,
2137
+ "sub-agent methods: { only: } would have no overlap with the parent's " \
2138
+ "methods: filter (child requested #{@method_filter_only.to_a.map(&:to_s).sort.inspect}). " \
2139
+ "A sub-agent cannot call agent methods its parent was not given. Pass a " \
2140
+ "subset of the parent's methods, or omit methods: to inherit them."
2141
+ end
2142
+ end
2143
+ if @method_filter_only || @method_filter_except
2144
+ @method_filter_layers << [@method_filter_only, @method_filter_except].freeze
2145
+ end
2146
+ @method_filter_layers.freeze
2147
+
2148
+ # Sub-agent class-filter inheritance. Like `tools:` above,
2149
+ # `classes:` clamps to the parent's effective set so a
2039
2150
  # sub-agent can NEVER widen its parent's data-reach. Intersect onlies,
2040
2151
  # union excepts. A child `only:` that would have no overlap with the
2041
2152
  # parent's effective set raises at construction — empty-onlyset means
@@ -2233,29 +2344,77 @@ module Parse
2233
2344
  #
2234
2345
  # An entry matches the invocation if it equals either the bare
2235
2346
  # method name (`:archive`) or the qualified form (`"Class.archive"`).
2347
+ # The class part is compared by its Parse class name on both sides, so
2348
+ # `"User.reset"` and `"_User.reset"` name the same method whichever
2349
+ # spelling the entry or the caller used.
2236
2350
  #
2237
2351
  # @param method_name [Symbol, String]
2238
2352
  # @param class_name [String]
2239
2353
  # @return [Boolean] true if filtered (refuse), false if permitted
2240
2354
  def method_filtered?(method_name, class_name:)
2241
- return false if @method_filter_only.nil? && @method_filter_except.nil?
2242
-
2243
2355
  method_sym = method_name.to_sym
2244
- qualified = "#{class_name}.#{method_name}"
2356
+ parse_class = Parse::Agent.canonical_method_class(class_name)
2245
2357
 
2246
- if @method_filter_only
2247
- permitted = @method_filter_only.include?(method_sym) ||
2248
- @method_filter_only.include?(qualified)
2249
- return true unless permitted
2358
+ # A sub-agent carries its parent's filters as earlier layers; its own
2359
+ # filter is the last layer. Any layer can refuse.
2360
+ method_filter_layers.any? do |only, except|
2361
+ (only && !method_entry_matches?(only, method_sym, parse_class)) ||
2362
+ (except && method_entry_matches?(except, method_sym, parse_class))
2250
2363
  end
2364
+ end
2251
2365
 
2252
- if @method_filter_except
2253
- excluded = @method_filter_except.include?(method_sym) ||
2254
- @method_filter_except.include?(qualified)
2255
- return true if excluded
2256
- end
2366
+ # The Parse class name for the class part of a qualified `methods:`
2367
+ # entry or a `call_method` class argument: `"User"` and `"_User"`
2368
+ # both give `"_User"`. A name that resolves to no model is returned
2369
+ # unchanged.
2370
+ # @api private
2371
+ # @param name [String, Class]
2372
+ # @return [String]
2373
+ def self.canonical_method_class(name)
2374
+ return name.parse_class if name.is_a?(Class) && name.respond_to?(:parse_class)
2375
+ str = name.to_s
2376
+ klass = resolve_method_filter_class(str)
2377
+ klass ? klass.parse_class : str
2378
+ end
2257
2379
 
2258
- false
2380
+ # Ruby constant names a `methods:` entry may use for a model whose Parse
2381
+ # class name differs (`class Artist < Parse::Object; parse_class
2382
+ # "Musician"; end`).
2383
+ RUBY_CONSTANT_NAME_RE = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
2384
+
2385
+ # @!visibility private
2386
+ # The Parse::Object subclass a `methods:` class name refers to, by
2387
+ # Parse class name first and then by Ruby constant name, or nil.
2388
+ def self.resolve_method_filter_class(str)
2389
+ klass = begin
2390
+ Parse::Model.find_class(str)
2391
+ rescue StandardError
2392
+ nil
2393
+ end
2394
+ return klass if klass.is_a?(Class) && klass < Parse::Object
2395
+ return nil unless str.match?(RUBY_CONSTANT_NAME_RE)
2396
+ klass = begin
2397
+ Object.const_get(str)
2398
+ rescue StandardError, LoadError
2399
+ nil
2400
+ end
2401
+ klass.is_a?(Class) && klass < Parse::Object ? klass : nil
2402
+ end
2403
+
2404
+ # @return [Array<Array(Set, Set)>] the `methods:` filters in effect as
2405
+ # `[only, except]` pairs, own filter last; a sub-agent carries its
2406
+ # parent's layers first.
2407
+ def method_filter_layers
2408
+ @method_filter_layers || []
2409
+ end
2410
+
2411
+ # @return [Array<Hash>] {#method_filter_layers} as `{only:, except:}`
2412
+ # hashes of sorted name strings (nil when a layer has no such list),
2413
+ # parent layers first. Used by the audit payload and {#describe}.
2414
+ def method_filter_layers_descriptor
2415
+ method_filter_layers.map do |only, except|
2416
+ { only: only && only.to_a.map(&:to_s).sort, except: except && except.to_a.map(&:to_s).sort }
2417
+ end
2259
2418
  end
2260
2419
 
2261
2420
  # @return [Boolean] whether unknown names in tools: raise vs. warn at
@@ -2487,6 +2646,15 @@ module Parse
2487
2646
  payload[:tools_except] = @tool_filter_except.to_a.sort if @tool_filter_except
2488
2647
  payload[:methods_only] = @method_filter_only.to_a.map(&:to_s).sort if @method_filter_only
2489
2648
  payload[:methods_except] = @method_filter_except.to_a.map(&:to_s).sort if @method_filter_except
2649
+ # `methods_only` / `methods_except` are this agent's own filter. A
2650
+ # sub-agent is also bound by its parent's filters, so the full set in
2651
+ # force is emitted as layers (parent first) whenever any layer was
2652
+ # inherited, including a single parent layer on a child with no
2653
+ # filter of its own.
2654
+ own_method_layers = (@method_filter_only || @method_filter_except) ? 1 : 0
2655
+ if method_filter_layers.size > own_method_layers
2656
+ payload[:methods_layers] = method_filter_layers_descriptor
2657
+ end
2490
2658
  # Per-agent per-class filters — emit class-name → field-name list,
2491
2659
  # NOT the constraint values. Filter values can contain user-identifying
2492
2660
  # data (`{ user_id: "abc123" }`, `{ org_id: tenant_uuid }`) that
@@ -3373,7 +3541,44 @@ module Parse
3373
3541
  # strings (qualified-class.method match).
3374
3542
  def normalize_method_filter_entry(value)
3375
3543
  str = value.to_s
3376
- str.include?(".") ? str : str.to_sym
3544
+ return str.to_sym unless str.include?(".")
3545
+ class_part, method_part = str.split(".", 2)
3546
+ if Parse::Agent.resolve_method_filter_class(class_part).nil?
3547
+ warn "[Parse::Agent] methods: entry #{str.inspect} names a class that does not " \
3548
+ "resolve to a loaded Parse::Object model (by Parse class name or Ruby " \
3549
+ "constant). It is matched by its class part as written; check the spelling."
3550
+ end
3551
+ "#{Parse::Agent.canonical_method_class(class_part)}.#{method_part}"
3552
+ end
3553
+
3554
+ # Whether a `methods:` filter set names this invocation, either bare or
3555
+ # qualified. Qualified entries are compared by Parse class name, which
3556
+ # also covers an entry whose class was not loaded at construction.
3557
+ def method_entry_matches?(set, method_sym, parse_class)
3558
+ return true if set.include?(method_sym) || set.include?("#{parse_class}.#{method_sym}")
3559
+ set.any? do |entry|
3560
+ next false unless entry.is_a?(String)
3561
+ class_part, method_part = entry.split(".", 2)
3562
+ method_part == method_sym.to_s && Parse::Agent.canonical_method_class(class_part) == parse_class
3563
+ end
3564
+ end
3565
+
3566
+ # Whether a child `methods:` only-entry could pass one parent layer.
3567
+ # A qualified entry ("Post.archive") passes when the layer names it or
3568
+ # its bare method. A bare entry (:archive) passes when the layer names
3569
+ # it bare or qualified on any class.
3570
+ def method_entry_reachable?(entry, layer_only, layer_except)
3571
+ if entry.is_a?(String)
3572
+ bare = entry.split(".", 2).last.to_sym
3573
+ return false if layer_except && (layer_except.include?(entry) || layer_except.include?(bare))
3574
+ return true unless layer_only
3575
+ layer_only.include?(entry) || layer_only.include?(bare)
3576
+ else
3577
+ return false if layer_except&.include?(entry)
3578
+ return true unless layer_only
3579
+ layer_only.include?(entry) ||
3580
+ layer_only.any? { |e| e.is_a?(String) && e.split(".", 2).last == entry.to_s }
3581
+ end
3377
3582
  end
3378
3583
 
3379
3584
  # Normalize the constructor's `classes:` kwarg into a [only_set,
@@ -3428,6 +3633,49 @@ module Parse
3428
3633
  # lookup re-expands the variants and accepts both forms symmetrically.
3429
3634
  #
3430
3635
  # @return [Hash, nil] frozen Hash or nil when no filters declared
3636
+ # Normalize the constructor's `fields:` kwarg: a Hash mapping class
3637
+ # identifiers (Class, String, Symbol, or :default) to an Array of field
3638
+ # names. Keys canonicalize like `filters:` so `Parse::User` matches both
3639
+ # "_User" and "User". Field names may be Ruby property names or wire
3640
+ # names; they are translated to wire form at lookup time.
3641
+ def normalize_field_policy(fields)
3642
+ return nil if fields.nil?
3643
+ unless fields.is_a?(Hash)
3644
+ raise ArgumentError,
3645
+ "fields: must be a Hash mapping class identifiers (or :default) " \
3646
+ "to Arrays of field names, got #{fields.class}"
3647
+ end
3648
+ result = {}
3649
+ fields.each do |key, names|
3650
+ unless names.is_a?(Array) && names.all? { |n| n.is_a?(String) || n.is_a?(Symbol) }
3651
+ raise ArgumentError,
3652
+ "fields[#{key.inspect}]: value must be an Array of field names, got #{names.inspect}"
3653
+ end
3654
+ field_policy_keys(key).each { |canon| result[canon] = names.map(&:to_s).freeze }
3655
+ end
3656
+ result.freeze
3657
+ end
3658
+
3659
+ # Canonical lookup names for a `fields:` key. Tools look policies up by
3660
+ # the class's Parse name (`_User`, or a custom `parse_class`), so a
3661
+ # String or Symbol key is resolved through Parse::Model.find_class to
3662
+ # that name as well as kept verbatim; otherwise `fields: { "User" => ... }`
3663
+ # would silently fail to narrow `_User`.
3664
+ def field_policy_keys(key)
3665
+ keys = canonical_filter_key(key)
3666
+ if key.is_a?(String) || key.is_a?(Symbol)
3667
+ klass = (Parse::Model.find_class(key.to_s) rescue nil)
3668
+ if klass.respond_to?(:parse_class)
3669
+ keys |= [klass.parse_class.to_s]
3670
+ if defined?(Parse::Agent::MetadataRegistry) &&
3671
+ Parse::Agent::MetadataRegistry.respond_to?(:hidden_name_variants_for)
3672
+ keys |= Parse::Agent::MetadataRegistry.hidden_name_variants_for(klass)
3673
+ end
3674
+ end
3675
+ end
3676
+ keys
3677
+ end
3678
+
3431
3679
  def normalize_query_filters(filters)
3432
3680
  return nil if filters.nil?
3433
3681
  unless filters.is_a?(Hash)
@@ -3567,6 +3815,35 @@ module Parse
3567
3815
  #
3568
3816
  # @param class_name [String, Symbol, Class]
3569
3817
  # @return [Boolean]
3818
+ # @return [Array<Hash>] the per-agent `fields:` policies in effect, own
3819
+ # policy last; a sub-agent carries its parent's layers first. Each
3820
+ # layer maps a canonical class name (or :default) to raw field names.
3821
+ attr_reader :field_policy_layers
3822
+
3823
+ # @return [Symbol] `:default` or `:server`; see {Parse::Agent::FieldNames}.
3824
+ attr_reader :field_names_mode
3825
+
3826
+ # Wire-format field names this agent narrows `class_name` to, or nil when
3827
+ # no `fields:` layer names the class (or a :default). Every layer that
3828
+ # applies must permit a field, so a sub-agent can never widen its
3829
+ # parent. The result is intersected with the class's `agent_fields`
3830
+ # ceiling by {Parse::Agent::MetadataRegistry.field_allowlist}.
3831
+ #
3832
+ # @param class_name [String, Class]
3833
+ # @return [Array<String>, nil]
3834
+ def field_narrowing_for(class_name)
3835
+ return nil if @field_policy_layers.nil? || @field_policy_layers.empty?
3836
+ cn = class_name.respond_to?(:parse_class) ? class_name.parse_class.to_s : class_name.to_s
3837
+ result = nil
3838
+ @field_policy_layers.each do |layer|
3839
+ names = layer[cn] || layer[:default]
3840
+ next if names.nil?
3841
+ wire = Parse::Agent::MetadataRegistry.wire_field_names(cn, names)
3842
+ result = result ? (result & wire) : wire
3843
+ end
3844
+ result
3845
+ end
3846
+
3570
3847
  def class_filter_permits?(class_name)
3571
3848
  return true if @class_filter_only.nil? && @class_filter_except.nil?
3572
3849
  candidates = class_name_variants_for(class_name)
@@ -26,7 +26,16 @@ module Parse
26
26
  batch_operations = Parse::BatchOperation.new batch_operations
27
27
  end
28
28
  response = request(:post, "batch", body: batch_operations.as_json)
29
- response.success? && response.batch? ? response.batch_responses : response
29
+ return response.batch_responses if response.success? && response.batch?
30
+ return response if response.error?
31
+ # A successful HTTP response whose body is not an array of results
32
+ # cannot be matched to the submitted requests. Report it as a failure
33
+ # rather than as a batch where every write landed.
34
+ Parse::Response.error_response(
35
+ Parse::Response::ERROR_INTERNAL,
36
+ "Malformed batch response: expected an array of results",
37
+ http_status: response.http_status,
38
+ ).tap { |r| r.request = response.request }
30
39
  end
31
40
  end
32
41
  end
@@ -8,11 +8,19 @@ module Parse
8
8
  # @!visibility private
9
9
  SCHEMAS_PATH = "schemas"
10
10
 
11
+ # Parse Server serves `/schemas` to the master key only. Every call
12
+ # here therefore asks for the master key explicitly (outside client
13
+ # mode), which also stops an ambient `Parse.with_session` token or a
14
+ # client-bound token from being attached in its place. With a session token attached the request was
15
+ # refused with 403, and callers that cache the class permissions (the
16
+ # CLP scope used by mongo-direct queries) then denied a direct query
17
+ # that the master-keyed client was entitled to run.
18
+
11
19
  # Get all the schemas for the application.
12
20
  # @param opts [Hash] additional options for the request.
13
21
  # @return [Parse::Response]
14
22
  def schemas(opts = {})
15
- request_opts = { cache: false }.merge(opts)
23
+ request_opts = { cache: false }.merge(schema_auth_opts).merge(opts)
16
24
  request :get, SCHEMAS_PATH, opts: request_opts
17
25
  end
18
26
 
@@ -21,7 +29,7 @@ module Parse
21
29
  # @return [Parse::Response]
22
30
  def schema(className)
23
31
  safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
24
- opts = { cache: false }
32
+ opts = { cache: false }.merge(schema_auth_opts)
25
33
  request :get, "#{SCHEMAS_PATH}/#{safe}", opts: opts
26
34
  end
27
35
 
@@ -32,7 +40,7 @@ module Parse
32
40
  # @return [Parse::Response]
33
41
  def create_schema(className, schema)
34
42
  safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
35
- request :post, "#{SCHEMAS_PATH}/#{safe}", body: schema
43
+ request :post, "#{SCHEMAS_PATH}/#{safe}", body: schema, opts: schema_auth_opts
36
44
  end
37
45
 
38
46
  # Update the schema for a collection.
@@ -42,7 +50,18 @@ module Parse
42
50
  # @return [Parse::Response]
43
51
  def update_schema(className, schema)
44
52
  safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
45
- request :put, "#{SCHEMAS_PATH}/#{safe}", body: schema
53
+ request :put, "#{SCHEMAS_PATH}/#{safe}", body: schema, opts: schema_auth_opts
54
+ end
55
+
56
+ private
57
+
58
+ # Ask for the master key explicitly, except under `Parse.client_mode`,
59
+ # whose contract is that the SDK never sends the master key on its own
60
+ # initiative. A client with no master key configured sends none either
61
+ # way.
62
+ # @!visibility private
63
+ def schema_auth_opts
64
+ { use_master_key: !Parse.client_mode }
46
65
  end
47
66
  end #Schema
48
67
  end #API
@@ -13,8 +13,12 @@ module Parse
13
13
  # @param opts [Hash] additional options to pass to the {Parse::Client} request.
14
14
  # @return [Parse::Response]
15
15
  def fetch_session(session_token, **opts)
16
- opts.merge!({ use_master_key: false, cache: false })
17
- headers = { Parse::Protocol::SESSION_TOKEN => session_token }
16
+ # The positional token is passed as the explicit per-call token so an
17
+ # ambient `Parse.with_session` token or a client-bound token cannot
18
+ # replace it and return another user's session.
19
+ session_token = session_token.session_token if session_token.respond_to?(:session_token)
20
+ opts = opts.merge({ use_master_key: false, cache: false, session_token: session_token.to_s })
21
+ headers = { Parse::Protocol::SESSION_TOKEN => session_token.to_s }
18
22
  response = request :get, "#{SESSION_PATH_PREFIX}/me", headers: headers, opts: opts
19
23
  response.parse_class = Parse::Model::CLASS_SESSION
20
24
  response