parse-stack-next 5.7.6 → 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 +830 -0
  3. data/README.md +14 -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 +181 -13
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +317 -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 +225 -8
  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
@@ -823,8 +823,10 @@ module Parse
823
823
  fields: {
824
824
  type: "array",
825
825
  items: { type: "string" },
826
- description: "Optional. Restrict search to these fields. When omitted, all indexed fields are " \
827
- "searched. Subject to the class's agent_fields allowlist when one is declared.",
826
+ description: "Optional. Restrict search to these fields. When omitted, the fields this agent " \
827
+ "may read are searched (every indexed field only when the class declares no " \
828
+ "agent_fields). A session-scoped search on a class with protected fields must name " \
829
+ "fields, and may not name a protected one.",
828
830
  },
829
831
  limit: {
830
832
  type: "integer",
@@ -1243,6 +1245,18 @@ module Parse
1243
1245
  # its declared timeout (handled by Agent#execute and the approval
1244
1246
  # preview, which both rescue it).
1245
1247
  def invoke(agent, name, **kwargs)
1248
+ # Every tool runs with the agent's per-agent `fields:` narrowing in
1249
+ # scope, so each allowlist check (MetadataRegistry.field_allowlist)
1250
+ # resolves to the effective set for THIS agent.
1251
+ # The agent's data-field naming mode (Parse::Agent::FieldNames) is
1252
+ # scoped the same way; it changes presentation only.
1253
+ Parse::Agent::FieldPolicy.with(agent) do
1254
+ Parse::Agent::FieldNames.with(agent) { invoke_unscoped(agent, name, **kwargs) }
1255
+ end
1256
+ end
1257
+
1258
+ # @!visibility private
1259
+ def invoke_unscoped(agent, name, **kwargs)
1246
1260
  sym = name.to_sym
1247
1261
  entry = REGISTRY_MUTEX.synchronize { @registry[sym] }
1248
1262
 
@@ -2031,16 +2045,24 @@ module Parse
2031
2045
  #
2032
2046
  # Raises `Parse::Agent::AccessDenied` on any breach. The `Agent#execute`
2033
2047
  # rescue chain translates that to `error_code: :access_denied`.
2048
+ #
2049
+ # @return [Array<String>, nil] the fields a result row may carry when
2050
+ # the source document reaches the output (no stage replaced the
2051
+ # schema), for {project_aggregate_rows}; nil when no projection is
2052
+ # needed (no `agent_fields`, or every output field was computed by a
2053
+ # validated stage).
2034
2054
  def enforce_pipeline_access_policy!(class_name, pipeline, agent: nil)
2035
- return unless pipeline.is_a?(Array)
2055
+ return nil unless pipeline.is_a?(Array)
2036
2056
  source_permitted = compute_source_allowlist_for(class_name)
2037
- walk_pipeline_with_state!(
2057
+ available, source_addressable = walk_pipeline_with_state!(
2038
2058
  pipeline,
2039
2059
  source_permitted: source_permitted,
2040
2060
  available: [],
2041
2061
  source_addressable: true,
2042
2062
  agent: agent,
2043
2063
  )
2064
+ return nil if source_permitted.nil? || !source_addressable
2065
+ source_permitted | available
2044
2066
  end
2045
2067
 
2046
2068
  module_function :enforce_pipeline_access_policy!
@@ -2058,6 +2080,26 @@ module Parse
2058
2080
 
2059
2081
  module_function :compute_source_allowlist_for
2060
2082
 
2083
+ # @api private
2084
+ # Drop top-level keys outside `permitted` from aggregation rows whose
2085
+ # source document passed through unreplaced. Checking the fields a
2086
+ # pipeline REFERENCES is not enough: `[{ "$limit" => 1 }]` references
2087
+ # nothing and returns every column. A `_p_<field>` storage key is kept
2088
+ # when `<field>` is permitted, and `_id` / `objectId` always are.
2089
+ def project_aggregate_rows(rows, permitted)
2090
+ return rows if permitted.nil?
2091
+ keep = permitted.map(&:to_s) | %w[_id objectId]
2092
+ Array(rows).map do |row|
2093
+ next row unless row.is_a?(Hash)
2094
+ row.select do |key, _|
2095
+ k = key.to_s
2096
+ keep.include?(k) || (k.start_with?("_p_") && keep.include?(k.delete_prefix("_p_")))
2097
+ end
2098
+ end
2099
+ end
2100
+
2101
+ module_function :project_aggregate_rows
2102
+
2061
2103
  # @api private
2062
2104
  # Forward-pass walker. Maintains two pieces of state across stages:
2063
2105
  # * `available` — fields introduced by upstream stages (Array<String>)
@@ -2088,6 +2130,7 @@ module Parse
2088
2130
  available = (available | introduced)
2089
2131
  end
2090
2132
  end
2133
+ [available, source_addressable]
2091
2134
  end
2092
2135
 
2093
2136
  module_function :walk_pipeline_with_state!
@@ -2364,6 +2407,38 @@ module Parse
2364
2407
  # MetadataRegistry.field_allowlist already merges in
2365
2408
  # ALWAYS_KEEP_FIELDS (objectId / createdAt / updatedAt), so a
2366
2409
  # join can carry the standard envelope without further work.
2410
+ # Join keys are reads too: joining on a hidden field and counting
2411
+ # the matches reveals its values even when the output is
2412
+ # projected away afterward. Source-side keys and `let`/
2413
+ # `startWith` expressions use the source allowlist; foreign-side
2414
+ # keys (and `restrictSearchWithMatch`) use the joined class's.
2415
+ if value.is_a?(Hash)
2416
+ opts = value.transform_keys(&:to_s)
2417
+ foreign_permitted = target_str ? compute_source_allowlist_for(target_str) : nil
2418
+ if permitted_fields
2419
+ %w[localField].each do |key|
2420
+ next unless opts[key]
2421
+ root = join_key_root(opts[key])
2422
+ unless permitted_fields.include?(root)
2423
+ raise_allowlist_refusal!("join field", opts[key].to_s, root, permitted_fields)
2424
+ end
2425
+ end
2426
+ opts["let"].each_value { |e| check_expression_for_restricted_fields!(e, permitted_fields) } if opts["let"].is_a?(Hash)
2427
+ check_expression_for_restricted_fields!(opts["startWith"], permitted_fields) if opts.key?("startWith")
2428
+ end
2429
+ if foreign_permitted
2430
+ %w[foreignField connectFromField connectToField].each do |key|
2431
+ next unless opts[key]
2432
+ root = join_key_root(opts[key])
2433
+ unless foreign_permitted.include?(root)
2434
+ raise_allowlist_refusal!("join field", opts[key].to_s, root, foreign_permitted)
2435
+ end
2436
+ end
2437
+ if opts["restrictSearchWithMatch"].is_a?(Hash)
2438
+ check_match_keys_for_restricted_fields!(opts["restrictSearchWithMatch"], foreign_permitted)
2439
+ end
2440
+ end
2441
+ end
2367
2442
  sub = value.is_a?(Hash) ? (value["pipeline"] || value[:pipeline]) : nil
2368
2443
  if sub
2369
2444
  # The lookup sub-pipeline runs against the FOREIGN class's
@@ -2503,6 +2578,20 @@ module Parse
2503
2578
 
2504
2579
  module_function :walk_pipeline_stage!
2505
2580
 
2581
+ # @api private
2582
+ # Root field of a join key in Parse terms. Joins are written against
2583
+ # stored documents, so `_id`, `_created_at`, `_updated_at`, and a
2584
+ # `_p_<pointer>` column name the objectId, timestamps, and pointer.
2585
+ JOIN_STORAGE_NAMES = { "_id" => "objectId", "_created_at" => "createdAt",
2586
+ "_updated_at" => "updatedAt" }.freeze
2587
+
2588
+ def join_key_root(key)
2589
+ root = key.to_s.split(".").first.to_s
2590
+ JOIN_STORAGE_NAMES.fetch(root) { root.delete_prefix("_p_") }
2591
+ end
2592
+
2593
+ module_function :join_key_root
2594
+
2506
2595
  # @api private
2507
2596
  # Walk a $match hash refusing field keys outside the allowlist.
2508
2597
  # Logical operators ($and/$or/$nor/$not) recurse. $expr expressions
@@ -2541,7 +2630,22 @@ module Parse
2541
2630
  def check_expression_for_restricted_fields!(expr, permitted_fields)
2542
2631
  case expr
2543
2632
  when String
2544
- if expr.start_with?("$") && !expr.start_with?("$$")
2633
+ if expr.start_with?("$$")
2634
+ # `$$ROOT` and `$$CURRENT` are the whole document: `$$ROOT.secret`
2635
+ # is `$secret`, and a bare `$$ROOT` copies every field. Other
2636
+ # system and user variables (`$$NOW`, `$$this`, `let` names) do
2637
+ # not address the source document.
2638
+ var, path = expr.delete_prefix("$$").split(".", 2)
2639
+ if %w[ROOT CURRENT].include?(var)
2640
+ ref = path.to_s.split(".").first
2641
+ if ref.nil? || ref.empty?
2642
+ raise_allowlist_refusal!("field reference", expr, "$$#{var}", permitted_fields)
2643
+ end
2644
+ unless permitted_fields.include?(ref)
2645
+ raise_allowlist_refusal!("field reference", expr, ref, permitted_fields)
2646
+ end
2647
+ end
2648
+ elsif expr.start_with?("$")
2545
2649
  ref = expr.sub(/\A\$/, "").split(".").first
2546
2650
  return if ref.empty? || ref.start_with?("$")
2547
2651
  unless permitted_fields.include?(ref)
@@ -3185,6 +3289,17 @@ module Parse
3185
3289
  order: nil, keys: nil, include: nil,
3186
3290
  apply_canonical_filter: true, format: nil, **_kwargs)
3187
3291
  assert_class_accessible!(class_name, agent: agent, op: :find)
3292
+ # Hidden-field inference: the caller's own where:/order: may only
3293
+ # reference readable fields. Filtering or sorting on a field outside
3294
+ # the effective agent_fields allowlist reveals its value through
3295
+ # which rows match or how they are ordered. Checked on the CALLER's
3296
+ # constraints, before the server-owned tenant / per-agent /
3297
+ # canonical constraints are merged in.
3298
+ assert_where_fields_in_allowlist!(class_name, where)
3299
+ assert_fields_in_allowlist!(class_name, order_field_names(order).map { |f| wire_field_path(class_name, f) })
3300
+ # Send the names that were checked: `email` resolves to the declared
3301
+ # column it was validated as, never to a same-named hidden column.
3302
+ order = wire_order(class_name, order)
3188
3303
  limit = [limit || Agent::DEFAULT_LIMIT, Agent::MAX_LIMIT].min
3189
3304
 
3190
3305
  # Tenant scope enforcement: resolve before any query building so that
@@ -3255,7 +3370,7 @@ module Parse
3255
3370
  # This blocks dangerous operators like $where, $function
3256
3371
  translated_where = nil
3257
3372
  if effective_where && !effective_where.empty?
3258
- translated_where = ConstraintTranslator.translate(effective_where, agent)
3373
+ translated_where = ConstraintTranslator.translate(effective_where, agent, class_name)
3259
3374
  query[:where] = translated_where.to_json
3260
3375
  end
3261
3376
 
@@ -3343,6 +3458,9 @@ module Parse
3343
3458
  # @return [Hash] count result
3344
3459
  def count_objects(agent, class_name:, where: nil, apply_canonical_filter: true, **_kwargs)
3345
3460
  assert_class_accessible!(class_name, agent: agent, op: :count)
3461
+ # Hidden-field inference: a count over a hidden field's values is an
3462
+ # oracle for that field, same as a filtered query.
3463
+ assert_where_fields_in_allowlist!(class_name, where)
3346
3464
  # Tenant scope enforcement. TRACK-AGENT-7 split: per-agent filter is
3347
3465
  # UNCONDITIONAL, canonical filter is LLM-controllable.
3348
3466
  scope = resolve_tenant_scope!(agent, class_name)
@@ -3354,7 +3472,7 @@ module Parse
3354
3472
 
3355
3473
  translated_where = nil
3356
3474
  if effective_where && !effective_where.empty?
3357
- translated_where = ConstraintTranslator.translate(effective_where, agent)
3475
+ translated_where = ConstraintTranslator.translate(effective_where, agent, class_name)
3358
3476
  query[:where] = translated_where.to_json
3359
3477
  end
3360
3478
 
@@ -3440,7 +3558,7 @@ module Parse
3440
3558
  else
3441
3559
  { "$and" => [composed_filter, { "objectId" => object_id }] }
3442
3560
  end
3443
- translated_combined = ConstraintTranslator.translate(combined_where, agent)
3561
+ translated_combined = ConstraintTranslator.translate(combined_where, agent, class_name)
3444
3562
  rows = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3445
3563
  execute_find_via_direct(
3446
3564
  agent, class_name,
@@ -3465,7 +3583,7 @@ module Parse
3465
3583
  # The three-layer ACL simulation in Parse::MongoDB.aggregate
3466
3584
  # ensures the row is only returned when the agent's scope
3467
3585
  # permits it.
3468
- where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent)
3586
+ where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent, class_name)
3469
3587
  rows = execute_find_via_direct(
3470
3588
  agent, class_name,
3471
3589
  where: where_id, limit: 1,
@@ -3574,7 +3692,7 @@ module Parse
3574
3692
  base_in_where = { "objectId" => { "$in" => unique_ids } }
3575
3693
  composed = apply_per_agent_filter_to_where(base_in_where, class_name, agent: agent)
3576
3694
  composed = apply_canonical_filter_to_where(composed, class_name, agent: agent) if apply_canonical_filter
3577
- translated_where = ConstraintTranslator.translate(composed, agent)
3695
+ translated_where = ConstraintTranslator.translate(composed, agent, class_name)
3578
3696
 
3579
3697
  # Build query
3580
3698
  query = {
@@ -3686,7 +3804,7 @@ module Parse
3686
3804
  effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent)
3687
3805
  translated_where = nil
3688
3806
  if effective_where && !effective_where.empty?
3689
- translated_where = ConstraintTranslator.translate(effective_where, agent)
3807
+ translated_where = ConstraintTranslator.translate(effective_where, agent, class_name)
3690
3808
  query[:where] = translated_where.to_json
3691
3809
  end
3692
3810
 
@@ -3760,7 +3878,7 @@ module Parse
3760
3878
  # class's agent_fields allowlist on projection-style stages
3761
3879
  # ($project, $addFields, $set, $unset, $replaceRoot). Without this
3762
3880
  # the top-level assert_class_accessible! check is bypassable.
3763
- enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
3881
+ output_fields = enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
3764
3882
 
3765
3883
  # Auto-rewrite LLM-style $lookup stages into Parse-on-Mongo column
3766
3884
  # form AFTER access policy has run on the LLM's original (logical)
@@ -3877,6 +3995,7 @@ module Parse
3877
3995
  # placeholder) and not silently surfaced into the pointer_classes
3878
3996
  # envelope map.
3879
3997
  results = redact_hidden_classes!(results, agent: agent)
3998
+ results = project_aggregate_rows(results, output_fields)
3880
3999
 
3881
4000
  # Pointer-column compaction. Default-on: a typical aggregate over
3882
4001
  # a class with a high-cardinality pointer (e.g. author per row)
@@ -3986,6 +4105,7 @@ module Parse
3986
4105
  formatted_value = value_field ? resolve_aggregation_field(class_name, validate_group_field!(value_field, name: :value_field)) : nil
3987
4106
 
3988
4107
  pipeline = build_group_pipeline(
4108
+ class_name: class_name,
3989
4109
  where: where,
3990
4110
  group_field: formatted_group,
3991
4111
  flatten_arrays: flatten_arrays,
@@ -4082,6 +4202,7 @@ module Parse
4082
4202
 
4083
4203
  date_expr = build_date_group_expression(formatted_field, interval_sym, tz)
4084
4204
  pipeline = build_group_pipeline(
4205
+ class_name: class_name,
4085
4206
  where: where,
4086
4207
  group_field: nil,
4087
4208
  group_expression: date_expr,
@@ -4150,6 +4271,7 @@ module Parse
4150
4271
 
4151
4272
  formatted_field = resolve_aggregation_field(class_name, validated_field)
4152
4273
  pipeline = build_group_pipeline(
4274
+ class_name: class_name,
4153
4275
  where: where,
4154
4276
  group_field: formatted_field,
4155
4277
  flatten_arrays: false,
@@ -4311,13 +4433,113 @@ module Parse
4311
4433
  def assert_where_fields_in_allowlist!(class_name, where)
4312
4434
  return unless where.is_a?(Hash) && !where.empty?
4313
4435
  allowlist = MetadataRegistry.field_allowlist(class_name)
4314
- return if allowlist.nil? || allowlist.empty?
4315
- permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
4316
- check_match_keys_for_restricted_fields!(where, permitted)
4436
+ if allowlist && allowlist.any?
4437
+ permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
4438
+ # Compare the names ConstraintTranslator will actually send:
4439
+ # `play_count` and `created_at` are permitted as `playCount` and
4440
+ # `createdAt`, and a `_p_` storage prefix is stripped.
4441
+ check_match_keys_for_restricted_fields!(normalize_where_keys(class_name, where), permitted)
4442
+ end
4443
+ # Embedded subqueries are validated against their OWN target class,
4444
+ # whether or not the outer class declares an allowlist.
4445
+ assert_subquery_fields_in_allowlist!(where)
4317
4446
  end
4318
4447
 
4319
4448
  module_function :assert_where_fields_in_allowlist!
4320
4449
 
4450
+ # @api private
4451
+ # A copy of `where` whose field keys (top level and inside
4452
+ # `$and`/`$or`/`$nor`/`$not`) are rewritten to their wire names, the way
4453
+ # ConstraintTranslator resolves them: the class's `field_map` entry or
4454
+ # an exact declared server name, else lowerCamelCase. A `_p_` storage
4455
+ # prefix is stripped. Operator keys and values are left alone; this is
4456
+ # used only to compare against the allowlist.
4457
+ def normalize_where_keys(class_name, where)
4458
+ case where
4459
+ when Hash
4460
+ where.each_with_object({}) do |(key, value), out|
4461
+ ks = key.to_s
4462
+ if %w[$and $or $nor].include?(ks)
4463
+ out[ks] = Array(value).map { |sub| normalize_where_keys(class_name, sub) }
4464
+ elsif ks == "$not"
4465
+ out[ks] = normalize_where_keys(class_name, value)
4466
+ elsif ks.start_with?("$")
4467
+ out[ks] = value
4468
+ else
4469
+ out[wire_field_path(class_name, ks)] = value
4470
+ end
4471
+ end
4472
+ else
4473
+ where
4474
+ end
4475
+ end
4476
+
4477
+ module_function :normalize_where_keys
4478
+
4479
+ # @api private
4480
+ # Wire form of a (possibly dotted) field path: the root segment is
4481
+ # resolved like a property name, the rest is kept.
4482
+ def wire_field_path(class_name, path)
4483
+ root, rest = path.to_s.sub(/\A_p_/, "").split(".", 2)
4484
+ wire = MetadataRegistry.wire_field_names(class_name, [root]).first || root
4485
+ rest ? "#{wire}.#{rest}" : wire
4486
+ end
4487
+
4488
+ module_function :wire_field_path
4489
+
4490
+ # @api private
4491
+ # Walk a where: Hash for embedded subqueries (`$inQuery`, `$notInQuery`,
4492
+ # `$select`, `$dontSelect`) and validate each one's predicates (and a
4493
+ # `$select` key) against the target class's effective allowlist. An
4494
+ # equivalent subquery must not reach a field a direct query on that
4495
+ # class would be refused, or the subquery becomes an oracle for it.
4496
+ # Class accessibility of the embedded className is enforced separately
4497
+ # by ConstraintTranslator.
4498
+ def assert_subquery_fields_in_allowlist!(node)
4499
+ case node
4500
+ when Hash
4501
+ node.each do |key, value|
4502
+ case key.to_s
4503
+ when "$inQuery", "$notInQuery"
4504
+ next unless value.is_a?(Hash)
4505
+ target = value["className"] || value[:className]
4506
+ inner = value["where"] || value[:where]
4507
+ assert_where_fields_in_allowlist!(target.to_s, inner) if target
4508
+ when "$select", "$dontSelect"
4509
+ next unless value.is_a?(Hash)
4510
+ query = value["query"] || value[:query] || {}
4511
+ target = query["className"] || query[:className]
4512
+ next unless target
4513
+ assert_where_fields_in_allowlist!(target.to_s, query["where"] || query[:where])
4514
+ selected = value["key"] || value[:key]
4515
+ assert_fields_in_allowlist!(target.to_s, [selected]) if selected
4516
+ when "$relatedTo"
4517
+ # The relation column lives on the OWNING object's class, so
4518
+ # `count_objects(_User, $relatedTo: {object: Post#X, key:
4519
+ # "flaggedBy"})` would reveal a relation hidden from Post's
4520
+ # allowlist unless the key is checked against that class.
4521
+ next unless value.is_a?(Hash)
4522
+ # Resolve the owner the way the translator does (a pointer Hash,
4523
+ # a Parse::Pointer, or a "Class$id" string).
4524
+ owner_class = ConstraintTranslator.send(:related_to_owning_class, value)
4525
+ relation_key = value["key"] || value[:key]
4526
+ if relation_key && (owner_class.nil? || owner_class.to_s.empty?)
4527
+ raise Parse::Agent::AccessDenied.new(
4528
+ nil, "$relatedTo requires a resolvable owning-object class.", kind: :field_denied,
4529
+ )
4530
+ end
4531
+ assert_fields_in_allowlist!(owner_class.to_s, [relation_key]) if relation_key
4532
+ else
4533
+ assert_subquery_fields_in_allowlist!(value)
4534
+ end
4535
+ end
4536
+ when Array
4537
+ node.each { |item| assert_subquery_fields_in_allowlist!(item) }
4538
+ end
4539
+ end
4540
+
4541
+ module_function :assert_subquery_fields_in_allowlist!
4542
+
4321
4543
  # @api private
4322
4544
  # Verify each referenced field is within agent_fields (or the
4323
4545
  # always-keep set) when an allowlist is declared on the class.
@@ -4329,15 +4551,42 @@ module Parse
4329
4551
  root = raw.to_s.sub(/\A_p_/, "").split(".").first
4330
4552
  next if root.nil? || root.empty?
4331
4553
  unless permitted.include?(root)
4332
- raise Parse::Agent::AccessDenied.new(
4333
- build_allowlist_refusal("field", raw.to_s, root, permitted),
4334
- )
4554
+ # raise_allowlist_refusal! carries kind/denied_field/allowed_fields
4555
+ # as structured attributes. Passing the refusal Hash positionally
4556
+ # (as this did before 5.8) landed it in the class-name slot, so the
4557
+ # message was a stringified Hash and `kind` was lost.
4558
+ raise_allowlist_refusal!("field", raw.to_s, root, permitted)
4335
4559
  end
4336
4560
  end
4337
4561
  end
4338
4562
 
4339
4563
  module_function :assert_fields_in_allowlist!
4340
4564
 
4565
+ # @api private
4566
+ # Field names an `order:` value sorts on. Accepts Parse REST form
4567
+ # ("-createdAt,title"), an Array of such entries, or nil.
4568
+ def order_field_names(order)
4569
+ return [] if order.nil?
4570
+ Array(order).flat_map { |entry| entry.to_s.split(",") }
4571
+ .map { |f| f.strip.sub(/\A[-+]/, "") }
4572
+ .reject(&:empty?)
4573
+ end
4574
+
4575
+ module_function :order_field_names
4576
+
4577
+ # @api private
4578
+ # `order` rewritten to wire names, keeping each key's `-`/`+` prefix:
4579
+ # `"-play_count,title"` becomes `"-playCount,title"`.
4580
+ def wire_order(class_name, order)
4581
+ return order if order.nil?
4582
+ Array(order).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?).map do |key|
4583
+ sign = key[/\A[-+]/].to_s
4584
+ "#{sign == "-" ? "-" : ""}#{wire_field_path(class_name, key.delete_prefix(sign))}"
4585
+ end.join(",")
4586
+ end
4587
+
4588
+ module_function :wire_order
4589
+
4341
4590
  # @api private
4342
4591
  # Resolve a wire-format field name to its MongoDB aggregation form.
4343
4592
  # Pointer fields are auto-prefixed with `_p_` when the local Parse
@@ -4370,10 +4619,10 @@ module Parse
4370
4619
  # nil we emit a bare $group with only _id (distinct).
4371
4620
  def build_group_pipeline(where:, group_field:, flatten_arrays:,
4372
4621
  accumulator_op:, value_field:, operation:,
4373
- group_expression: nil, agent: nil)
4622
+ group_expression: nil, agent: nil, class_name: nil)
4374
4623
  pipeline = []
4375
4624
  if where.is_a?(Hash) && !where.empty?
4376
- pipeline << { "$match" => ConstraintTranslator.translate(where, agent) }
4625
+ pipeline << { "$match" => ConstraintTranslator.translate(where, agent, class_name) }
4377
4626
  end
4378
4627
  if flatten_arrays && group_field
4379
4628
  pipeline << { "$unwind" => "$#{group_field}" }
@@ -4772,6 +5021,17 @@ module Parse
4772
5021
 
4773
5022
  # @api private
4774
5023
  def export_via_query(agent, class_name:, where:, keys:, include:, order:, limit:, skip: nil, scope: nil)
5024
+ # Hidden-field inference: the caller's own where:/order: may only
5025
+ # reference readable fields. Filtering or sorting on a field outside
5026
+ # the effective agent_fields allowlist reveals its value through
5027
+ # which rows match or how they are ordered. Checked on the CALLER's
5028
+ # constraints, before the server-owned tenant / per-agent /
5029
+ # canonical constraints are merged in.
5030
+ assert_where_fields_in_allowlist!(class_name, where)
5031
+ assert_fields_in_allowlist!(class_name, order_field_names(order).map { |f| wire_field_path(class_name, f) })
5032
+ # Send the names that were checked: `email` resolves to the declared
5033
+ # column it was validated as, never to a same-named hidden column.
5034
+ order = wire_order(class_name, order)
4775
5035
  # Reuse query_class's gates by routing through it directly.
4776
5036
  # query_class returns a ResultFormatter-wrapped hash; we want the raw rows.
4777
5037
  query = {}
@@ -4817,7 +5077,7 @@ module Parse
4817
5077
  effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent)
4818
5078
  translated_where = nil
4819
5079
  if effective_where && !effective_where.empty?
4820
- translated_where = ConstraintTranslator.translate(effective_where, agent)
5080
+ translated_where = ConstraintTranslator.translate(effective_where, agent, class_name)
4821
5081
  query[:where] = translated_where.to_json
4822
5082
  end
4823
5083
 
@@ -4844,7 +5104,7 @@ module Parse
4844
5104
  # @api private
4845
5105
  def export_via_aggregate(agent, class_name:, pipeline:, scope: nil)
4846
5106
  PipelineValidator.validate!(pipeline)
4847
- enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
5107
+ output_fields = enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
4848
5108
  assert_joins_tenant_safe!(pipeline, scope)
4849
5109
  # Prepend tenant scope $match before per-agent + canonical filter and auto-limit.
4850
5110
  scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
@@ -4883,7 +5143,7 @@ module Parse
4883
5143
  end
4884
5144
  end
4885
5145
 
4886
- redact_hidden_classes!(rows, agent: agent)
5146
+ project_aggregate_rows(redact_hidden_classes!(rows, agent: agent), output_fields)
4887
5147
  end
4888
5148
 
4889
5149
  module_function :export_via_aggregate
@@ -5075,6 +5335,9 @@ module Parse
5075
5335
  # @return [Hash] query explanation
5076
5336
  def explain_query(agent, class_name:, where: nil, **_kwargs)
5077
5337
  assert_class_accessible!(class_name, agent: agent, op: :find)
5338
+ # Explain stats (nReturned) answer yes/no for any predicate, so a
5339
+ # hidden field must not be addressable here either.
5340
+ assert_where_fields_in_allowlist!(class_name, where)
5078
5341
  # No direct-MongoDB equivalent of Parse Server's REST explain
5079
5342
  # plan exists today, and routing this through master-key REST
5080
5343
  # under an acl_user/acl_role agent would silently bypass the
@@ -5100,7 +5363,7 @@ module Parse
5100
5363
  effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent)
5101
5364
 
5102
5365
  if effective_where && !effective_where.empty?
5103
- query[:where] = ConstraintTranslator.translate(effective_where, agent).to_json
5366
+ query[:where] = ConstraintTranslator.translate(effective_where, agent, class_name).to_json
5104
5367
  end
5105
5368
 
5106
5369
  response = agent.client.find_objects(class_name, query, **agent.request_opts)
@@ -5475,7 +5738,7 @@ module Parse
5475
5738
  # @return [Hash, nil]
5476
5739
  def run_explain(agent, class_name, where)
5477
5740
  query = { explain: true, limit: 1 }
5478
- query[:where] = ConstraintTranslator.translate(where, agent).to_json
5741
+ query[:where] = ConstraintTranslator.translate(where, agent, class_name).to_json
5479
5742
  response = agent.client.find_objects(class_name, query, **agent.request_opts)
5480
5743
  return nil unless response.success?
5481
5744
  response.result
@@ -5687,10 +5950,29 @@ module Parse
5687
5950
  # also packs a reference to the assignee `_User`) would otherwise
5688
5951
  # leak fields the conversational `query_class` tool would refuse
5689
5952
  # to return.
5953
+ #
5954
+ # A returned Parse::Object is serialized from `as_json`, which carries
5955
+ # its values under the Parse (wire) field names, including explicit
5956
+ # `field_map` aliases and nested JSON verbatim. Before 5.8 this read
5957
+ # `result.attributes`, which is the model's field TYPE map, so a
5958
+ # method returning an object emitted `{ "title" => :string }` instead
5959
+ # of its data. An AggregationResult (e.g. a method returning
5960
+ # `query.aggregate(...).results`) is emitted as a Hash: snake_case keys
5961
+ # by default, the aggregation's own keys under `field_names: :server`.
5962
+ # Before 5.8 it was emitted as its `inspect` String.
5690
5963
  def serialize_result(result, agent: nil)
5691
5964
  formatted = case result
5692
5965
  when Parse::Object
5693
- project_object_to_allowlist(result.parse_class, ResultFormatter.format_object(result.parse_class, result.attributes)[:object])
5966
+ project_object_to_allowlist(result.parse_class, ResultFormatter.simplify_object(result.as_json))
5967
+ when Parse::AggregationResult
5968
+ source = Parse::Agent::FieldNames.server? ? result.raw : result.to_h
5969
+ row = source.each_with_object({}) { |(k, v), h| h[k.to_s] = serialize_result(v, agent: agent) }
5970
+ # A row is a computed shape with no owning class, so it cannot be
5971
+ # projected through an allowlist; Parse Server's internal columns
5972
+ # (`_rperm`, `_hashed_password`, `_auth_data_*`, ...) are removed
5973
+ # from it at every depth, as on every other aggregation path.
5974
+ Parse::PipelineSecurity.redact_internal_fields_deep!(row)
5975
+ row
5694
5976
  when Array
5695
5977
  result.map { |item| serialize_result(item, agent: agent) }
5696
5978
  when Hash
@@ -5700,7 +5982,30 @@ module Parse
5700
5982
  else
5701
5983
  result.to_s
5702
5984
  end
5703
- redact_hidden_classes!(formatted, agent: agent)
5985
+ redact_hidden_classes!(project_embedded_objects(formatted), agent: agent)
5986
+ end
5987
+
5988
+ # @api private
5989
+ # Project every embedded Parse object in a formatted result through
5990
+ # its OWN class's effective allowlist (class `agent_fields` narrowed by
5991
+ # the executing agent's `fields:` policy). A method may return an
5992
+ # object whose included children, or a plain Hash holding object JSON,
5993
+ # carry fields the caller may not read; projecting only the top-level
5994
+ # class would leak them. An embedded object is any Hash carrying a
5995
+ # `className`, saved or not: an unsaved object has no `objectId` but
5996
+ # still holds field values. A bare pointer keeps only its className,
5997
+ # `__type`, and `objectId`, so projecting it is harmless.
5998
+ def project_embedded_objects(value)
5999
+ case value
6000
+ when Hash
6001
+ class_name = value["className"] || value[:className]
6002
+ projected = class_name ? project_object_to_allowlist(class_name.to_s, value) : value
6003
+ projected.each_with_object({}) { |(k, v), acc| acc[k] = project_embedded_objects(v) }
6004
+ when Array
6005
+ value.map { |item| project_embedded_objects(item) }
6006
+ else
6007
+ value
6008
+ end
5704
6009
  end
5705
6010
 
5706
6011
  # @api private
@@ -5799,6 +6104,15 @@ module Parse
5799
6104
  limit = clamp_atlas_limit(limit)
5800
6105
  auth = atlas_auth_options!(agent, tool: :atlas_text_search)
5801
6106
  fields_norm = normalize_atlas_fields_with_allowlist!(class_name, fields)
6107
+ # Hidden-field inference: the caller's filter may only address
6108
+ # readable fields, and with no `fields:` the text search defaults to
6109
+ # the readable fields rather than every field (`wildcard: "*"`), so
6110
+ # neither which rows match nor their rank depends on a hidden field.
6111
+ assert_where_fields_in_allowlist!(class_name, filter) if filter.is_a?(Hash)
6112
+ fields_norm ||= readable_atlas_text_fields(class_name)
6113
+ # An empty field list means `wildcard: "*"` to Atlas Search, which
6114
+ # would let hidden fields decide matches; refuse instead.
6115
+ refuse_empty_readable_text_fields!(class_name, fields_norm)
5802
6116
 
5803
6117
  # TRACK-AGENT-6 / TRACK-AGENT-7 fix: per-agent filter is
5804
6118
  # UNCONDITIONAL; canonical filter is LLM-controllable via
@@ -5911,9 +6225,19 @@ module Parse
5911
6225
  # Parse::AtlasSearch::FacetedSearchNotACLSafe); pass master:
5912
6226
  # true unconditionally here, since the agent-level gate
5913
6227
  # above already enforced master_atlas?.
6228
+ readable = readable_atlas_text_fields(class_name)
6229
+ facet_opts = {}
6230
+ # A non-empty query searches only readable fields (never a
6231
+ # wildcard across hidden ones) when an allowlist applies.
6232
+ if readable && !query.to_s.strip.empty?
6233
+ refuse_empty_readable_text_fields!(class_name, readable)
6234
+ facet_opts[:fields] = readable
6235
+ end
6236
+ # Parse::AtlasSearch is loaded on first use, not with the agent.
6237
+ require_relative "../atlas_search"
5914
6238
  result = Parse::AtlasSearch.faceted_search(
5915
6239
  class_name, query.to_s, facets,
5916
- limit: limit, master: true,
6240
+ limit: limit, master: true, **facet_opts,
5917
6241
  # Master mode still has to name its application: the binding
5918
6242
  # guard compares the client, not the posture, and an unnamed
5919
6243
  # caller is refused once two applications are in play.
@@ -6063,7 +6387,7 @@ module Parse
6063
6387
  def fetch_call_method_receiver(agent, klass, class_name, object_id)
6064
6388
  scope = resolve_tenant_scope!(agent, class_name)
6065
6389
  result = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
6066
- where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent)
6390
+ where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent, class_name)
6067
6391
  rows = execute_find_via_direct(agent, class_name, where: where_id, limit: 1)
6068
6392
  rows && rows.first
6069
6393
  else
@@ -6196,6 +6520,27 @@ module Parse
6196
6520
  module_function :compose_atlas_filter
6197
6521
 
6198
6522
  # @api private
6523
+ # @api private
6524
+ # The fields an Atlas text search may run over when the caller named
6525
+ # none: the effective allowlist minus the always-keep system fields,
6526
+ # or nil when no allowlist applies (the wildcard is then harmless).
6527
+ def readable_atlas_text_fields(class_name)
6528
+ allowlist = Parse::Agent::MetadataRegistry.field_allowlist(class_name)
6529
+ return nil if allowlist.nil? || allowlist.empty?
6530
+ allowlist.map(&:to_s) - Parse::Agent::MetadataRegistry::ALWAYS_KEEP_FIELDS
6531
+ end
6532
+
6533
+ # @api private
6534
+ def refuse_empty_readable_text_fields!(class_name, fields)
6535
+ return unless fields.is_a?(Array) && fields.empty?
6536
+ raise Parse::Agent::AccessDenied.new(
6537
+ class_name,
6538
+ "No readable fields to text-search on class '#{class_name}' under this agent's field policy.",
6539
+ kind: :field_denied,
6540
+ allowed_fields: Parse::Agent::MetadataRegistry.field_allowlist(class_name)&.map(&:to_s),
6541
+ )
6542
+ end
6543
+
6199
6544
  def assert_atlas_field_allowed!(class_name, field_name, kind:)
6200
6545
  name = field_name.to_s
6201
6546
  allowlist = Parse::Agent::MetadataRegistry.field_allowlist(class_name)
@@ -6251,6 +6596,10 @@ module Parse
6251
6596
  # carry implementation details (toggle names, internal stage
6252
6597
  # ordering) that aren't useful in an LLM tool-call response.
6253
6598
  def invoke_atlas_search(op, class_name, query, opts, **extra)
6599
+ # Parse::AtlasSearch is loaded on first use, not with the agent. Load
6600
+ # it before the body runs so the rescue clauses below can resolve
6601
+ # its error classes.
6602
+ require_relative "../atlas_search"
6254
6603
  case op
6255
6604
  when :search
6256
6605
  Parse::AtlasSearch.search(class_name, query, **opts)