parse-stack-next 5.4.1 → 5.5.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +61 -9
  5. data/docs/atlas_vector_search_guide.md +318 -19
  6. data/lib/parse/acl_scope.rb +11 -0
  7. data/lib/parse/agent/mcp_rack_app.rb +53 -14
  8. data/lib/parse/agent/mcp_server.rb +19 -0
  9. data/lib/parse/api/path_segment.rb +31 -0
  10. data/lib/parse/api/users.rb +13 -0
  11. data/lib/parse/cache/redis.rb +55 -11
  12. data/lib/parse/client/caching.rb +12 -3
  13. data/lib/parse/client/logging.rb +9 -0
  14. data/lib/parse/client.rb +37 -3
  15. data/lib/parse/embeddings/batch_embedder.rb +188 -0
  16. data/lib/parse/embeddings/cache.rb +374 -0
  17. data/lib/parse/embeddings/cohere.rb +31 -18
  18. data/lib/parse/embeddings/image_fetch.rb +347 -0
  19. data/lib/parse/embeddings/provider.rb +17 -11
  20. data/lib/parse/embeddings/spend_cap.rb +117 -3
  21. data/lib/parse/embeddings/voyage.rb +34 -25
  22. data/lib/parse/embeddings.rb +40 -3
  23. data/lib/parse/model/acl.rb +15 -11
  24. data/lib/parse/model/core/embed_managed.rb +243 -14
  25. data/lib/parse/model/core/properties.rb +42 -5
  26. data/lib/parse/model/core/vector_searchable.rb +157 -8
  27. data/lib/parse/mongodb.rb +12 -0
  28. data/lib/parse/pipeline_security.rb +81 -15
  29. data/lib/parse/query/constraint.rb +22 -0
  30. data/lib/parse/query/constraints.rb +271 -250
  31. data/lib/parse/query.rb +284 -43
  32. data/lib/parse/retrieval/agent_tool.rb +21 -14
  33. data/lib/parse/retrieval/retriever.rb +84 -0
  34. data/lib/parse/schema/search_index_migrator.rb +48 -1
  35. data/lib/parse/stack/version.rb +1 -1
  36. data/lib/parse/stack.rb +12 -1
  37. data/lib/parse/vector_search/hybrid.rb +39 -1
  38. data/lib/parse/vector_search.rb +34 -0
  39. data/lib/parse/webhooks/payload.rb +7 -1
  40. data/lib/parse/webhooks.rb +107 -21
  41. metadata +4 -1
data/lib/parse/query.rb CHANGED
@@ -1252,14 +1252,32 @@ module Parse
1252
1252
  pipeline, has_lookup_stages = build_aggregation_pipeline
1253
1253
  pipeline << { "$count" => "count" }
1254
1254
 
1255
- # Auto-detect if MongoDB direct is needed
1255
+ # Auto-detect if MongoDB direct is needed. Mirror the routing in
1256
+ # #execute_aggregation_pipeline: a pipeline that references internal
1257
+ # ACL columns (_rperm/_wperm via readable_by/publicly_readable and
1258
+ # friends) MUST run mongo-direct — Parse Server's REST aggregate
1259
+ # endpoint cannot express a $match on those columns — and the
1260
+ # mongo-direct sink must be told the references are sanctioned so
1261
+ # the PipelineSecurity internal-fields denylist lets them through.
1262
+ uses_internal_fields = pipeline_uses_internal_fields?(pipeline)
1263
+ scoped = distinct_query_is_scoped?
1256
1264
  use_mongo_direct = false
1257
- if has_lookup_stages && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
1265
+ if defined?(@acl_query_mongo_direct) && !@acl_query_mongo_direct.nil?
1266
+ use_mongo_direct = @acl_query_mongo_direct
1267
+ elsif (scoped || has_lookup_stages || uses_internal_fields) &&
1268
+ defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
1258
1269
  use_mongo_direct = true
1270
+ elsif scoped
1271
+ # Same fail-closed contract as #aggregate / #aggregate_from_query:
1272
+ # a scoped count must not fall back to REST /aggregate, which
1273
+ # would drop the scope and count rows the caller cannot read.
1274
+ raise_scoped_aggregation_requires_mongo_direct!
1259
1275
  end
1260
1276
 
1261
1277
  # Execute aggregation
1262
- aggregation = Aggregation.new(self, pipeline, verbose: @verbose_aggregate, mongo_direct: use_mongo_direct)
1278
+ aggregation = Aggregation.new(self, pipeline, verbose: @verbose_aggregate,
1279
+ mongo_direct: use_mongo_direct,
1280
+ allow_internal_fields: uses_internal_fields)
1263
1281
  response = aggregation.execute!
1264
1282
 
1265
1283
  # Extract count from aggregation result
@@ -1803,6 +1821,25 @@ module Parse
1803
1821
  false
1804
1822
  end
1805
1823
 
1824
+ # Fail closed for a scoped aggregation that would otherwise fall back
1825
+ # to REST /aggregate. That endpoint is master-key-only and enforces
1826
+ # neither ACL nor CLP, so letting a scoped query through would silently
1827
+ # run it unscoped as the master key. Every aggregation terminal that
1828
+ # routes a scoped query (aggregate, aggregate_from_query, count,
1829
+ # execute_aggregation_pipeline) raises through here.
1830
+ # @raise [MongoDirectRequired]
1831
+ # @api private
1832
+ def raise_scoped_aggregation_requires_mongo_direct!
1833
+ raise MongoDirectRequired,
1834
+ "[Parse::Query] This scoped aggregation (session_token / " \
1835
+ "scope_to_user / scope_to_role) requires mongo-direct so the " \
1836
+ "SDK can enforce ACL/CLP. Parse Server's REST /aggregate " \
1837
+ "endpoint is master-key-only and enforces neither, so routing " \
1838
+ "it there would silently run unscoped as the master key. " \
1839
+ "Enable mongo-direct via Parse::MongoDB.configure(...), or " \
1840
+ "rewrite without the aggregation terminal."
1841
+ end
1842
+
1806
1843
  # Scope a query to a specific user's row-level ACL when it auto-routes
1807
1844
  # through mongo-direct. The SDK records the user, computes the
1808
1845
  # effective `_rperm` allow-set (user objectId + `"*"` + every role
@@ -1928,11 +1965,21 @@ module Parse
1928
1965
  # roles, injects the three-layer ACL simulation
1929
1966
  # (top-level `$match`, `$lookup` rewriter, post-fetch
1930
1967
  # redactor) via {Parse::MongoDB.aggregate}.
1968
+ # * an active `Parse.with_session` block — the fiber-local ambient
1969
+ # session token scopes the read the same way an explicit
1970
+ # `session_token=` would (see {#mongo_direct_auth_kwargs}).
1931
1971
  #
1932
1972
  # Raises a clear {MongoDirectRequired} otherwise.
1933
1973
  # @!visibility private
1934
1974
  def assert_mongo_direct_routable!
1935
1975
  has_session = @session_token.is_a?(String) && !@session_token.empty?
1976
+ # An active `Parse.with_session` block scopes the read even on a
1977
+ # non-master client (client_mode, or a user-scoped client with no
1978
+ # master key), where `server_mode_master` is false. Without this the
1979
+ # query would raise instead of running scoped — and on a master
1980
+ # client the ambient is what `mongo_direct_auth_kwargs` forwards so
1981
+ # the read is scoped rather than silently master.
1982
+ has_ambient_session = !ambient_session_token.nil?
1936
1983
  # Mirror the request-layer auth resolution in Parse::Client#request:
1937
1984
  # when the process is in "server mode" — Parse.client_mode == false
1938
1985
  # AND the resolved Parse::Client has a master_key — and the caller
@@ -1948,7 +1995,7 @@ module Parse
1948
1995
  false
1949
1996
  end
1950
1997
  server_mode_master = (use_master_key != false) && !Parse.client_mode && client_has_master_key
1951
- unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session
1998
+ unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session || has_ambient_session
1952
1999
  raise MongoDirectRequired,
1953
2000
  "[Parse::Query] This query uses a constraint that can only run " \
1954
2001
  "via mongo-direct. Mongo-direct bypasses Parse Server's enforcement, " \
@@ -1982,6 +2029,12 @@ module Parse
1982
2029
  # double-inject).
1983
2030
  # * `session_token` is set → forward `session_token:` so
1984
2031
  # Parse::ACLScope runs the full three-layer simulation.
2032
+ # * Otherwise, the fiber-local ambient session set by
2033
+ # `Parse.with_session` is forwarded as `session_token:` (unless
2034
+ # the caller explicitly requested `use_master_key: true`), so a
2035
+ # query that auto-routes to mongo-direct inside a `with_session`
2036
+ # block is scoped to that user — matching what the REST path does
2037
+ # in {Parse::Client#request}.
1985
2038
  # * Otherwise (master-key path) → forward `master: true`.
1986
2039
  # @!visibility private
1987
2040
  def mongo_direct_auth_kwargs
@@ -1999,11 +2052,32 @@ module Parse
1999
2052
  { acl_role: @acl_role }
2000
2053
  elsif @session_token.is_a?(String) && !@session_token.empty?
2001
2054
  { session_token: @session_token }
2055
+ elsif use_master_key != true && (ambient = ambient_session_token)
2056
+ # No explicit per-query scope, but a `Parse.with_session` block is
2057
+ # active. Mirror Parse::Client#request's precedence (ambient
2058
+ # session wins over the server-mode master default) so the read is
2059
+ # scoped to that user instead of silently running as master with
2060
+ # no ACL/CLP enforcement. An explicit `use_master_key: true` is a
2061
+ # deliberate admin call and skips the ambient, exactly as the REST
2062
+ # path does.
2063
+ { session_token: ambient }
2002
2064
  else
2003
2065
  { master: true }
2004
2066
  end
2005
2067
  end
2006
2068
 
2069
+ # The fiber-local ambient session token set by `Parse.with_session`,
2070
+ # or nil. A whitespace-only ambient is treated as absent so it cannot
2071
+ # block the master fallback and then fail a later presence check —
2072
+ # the same guard {Parse::Client#request} applies.
2073
+ # @return [String, nil]
2074
+ # @!visibility private
2075
+ def ambient_session_token
2076
+ return nil unless Parse.respond_to?(:current_session_token)
2077
+ ambient = Parse.current_session_token
2078
+ ambient if ambient.is_a?(String) && !ambient.strip.empty?
2079
+ end
2080
+
2007
2081
  # Check if this query contains constraints that require aggregation pipeline processing
2008
2082
  # @return [Boolean] true if aggregation pipeline is required
2009
2083
  def requires_aggregation_pipeline?
@@ -3464,15 +3538,60 @@ module Parse
3464
3538
  complete_pipeline << { "$limit" => @limit }
3465
3539
  end
3466
3540
 
3467
- # Auto-detect if mongo_direct is needed (when $inQuery constraints are present and MongoDB is available)
3468
- use_mongo_direct = mongo_direct
3469
- if use_mongo_direct.nil? && lookup_stages && lookup_stages.any? && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3470
- use_mongo_direct = true
3471
- end
3472
-
3473
3541
  # Optimize pipeline by merging consecutive $match stages
3474
3542
  complete_pipeline = deduplicate_consecutive_match_stages(complete_pipeline)
3475
3543
 
3544
+ # Auto-detect whether this aggregation must run via the direct-MongoDB
3545
+ # path instead of Parse Server's REST /aggregate endpoint. Three
3546
+ # independent triggers, each of which REST /aggregate cannot serve:
3547
+ #
3548
+ # * $inQuery / $notInQuery → $lookup stages (the original trigger).
3549
+ # * An SDK-injected ACL $match on the internal _rperm / _wperm columns
3550
+ # (readable_by / publicly_readable / writable_by and friends). Parse
3551
+ # Server's REST aggregate rejects a $match on those columns.
3552
+ # * A scoped query (session_token / scope_to_user / scope_to_role).
3553
+ # REST /aggregate is master-key-only and enforces NEITHER ACL NOR
3554
+ # CLP, so a scoped aggregate sent over REST silently runs unscoped
3555
+ # as the master key — leaking sums/min/max/distinct over rows the
3556
+ # caller cannot read. This is the same enforcement asymmetry the
3557
+ # #distinct / #count / #results auto-routes already guard against;
3558
+ # the scalar terminals (sum/average/min/max/count_distinct) all
3559
+ # funnel through here, so routing them here fixes every one.
3560
+ #
3561
+ # `allow_internal_fields` is forwarded for internal-field pipelines: the
3562
+ # caller-supplied `pipeline` arg was validated above (line ~3343) with
3563
+ # the internal-fields denylist active, so any _rperm/_wperm reference in
3564
+ # the merged pipeline is provably SDK-injected, never user input.
3565
+ uses_internal_fields = pipeline_uses_internal_fields?(complete_pipeline)
3566
+ scoped = distinct_query_is_scoped?
3567
+ mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3568
+ use_mongo_direct = mongo_direct
3569
+
3570
+ if scoped
3571
+ # A scoped aggregation (session_token / scope_to_user / scope_to_role)
3572
+ # must NEVER reach Parse Server's REST /aggregate endpoint — it is
3573
+ # master-key-only and enforces NEITHER ACL NOR CLP, so it would run
3574
+ # unscoped as the master key. This holds even when the caller
3575
+ # explicitly passes `mongo_direct: false`: an explicit false cannot
3576
+ # opt a scoped query out of ACL/CLP enforcement. Promote to mongo-
3577
+ # direct, or fail closed when direct Mongo is unavailable (refuse
3578
+ # rather than leak unscoped rows).
3579
+ if mongo_ready
3580
+ use_mongo_direct = true
3581
+ else
3582
+ raise_scoped_aggregation_requires_mongo_direct!
3583
+ end
3584
+ elsif use_mongo_direct.nil?
3585
+ # Unscoped auto-routing: $inQuery/$notInQuery → $lookup pipelines and
3586
+ # SDK-injected internal-field ($rperm/_wperm) pipelines can't be served
3587
+ # by REST /aggregate, so prefer mongo-direct when available. An unscoped
3588
+ # internal-field pipeline keeps the REST fallback (a master-key
3589
+ # correctness edge, not an enforcement bypass).
3590
+ if (lookup_stages && lookup_stages.any?) || uses_internal_fields
3591
+ use_mongo_direct = true if mongo_ready
3592
+ end
3593
+ end
3594
+
3476
3595
  # When the pipeline is bound for direct MongoDB, translate every stage
3477
3596
  # through the direct-MongoDB field rewriter so user-supplied stages
3478
3597
  # (which use logical Parse field names like `$author`) reach the
@@ -3484,6 +3603,7 @@ module Parse
3484
3603
  end
3485
3604
 
3486
3605
  Aggregation.new(self, complete_pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false,
3606
+ allow_internal_fields: uses_internal_fields,
3487
3607
  raw_values: raw_values, raw_field_names: raw_field_names)
3488
3608
  end
3489
3609
 
@@ -3550,17 +3670,44 @@ module Parse
3550
3670
  # Build pipeline from current query constraints
3551
3671
  pipeline, has_lookup_stages = build_query_aggregate_pipeline
3552
3672
 
3673
+ # `allow_internal_fields` is computed from the SDK-built portion ONLY
3674
+ # (before appending caller stages): build_query_aggregate_pipeline emits
3675
+ # the _rperm/_wperm $match for readable_by/etc., but `additional_stages`
3676
+ # is caller-supplied and NOT validated here, so we must not sanction an
3677
+ # internal-field reference the caller smuggled in. A scoped query still
3678
+ # routes to mongo-direct regardless (so ACL/CLP enforcement runs).
3679
+ uses_internal_fields = pipeline_uses_internal_fields?(pipeline)
3680
+
3553
3681
  # Append any additional stages
3554
3682
  pipeline.concat(additional_stages) if additional_stages.any?
3555
3683
 
3556
- # Auto-detect if mongo_direct is needed (when $inQuery constraints are present and MongoDB is available)
3684
+ # Same routing contract as #aggregate: $lookup subqueries, an SDK ACL
3685
+ # $match, or a scoped query each require the direct-MongoDB path (REST
3686
+ # /aggregate cannot express _rperm/_wperm and is master-key-only/
3687
+ # unenforced). A scoped query fails closed when mongo-direct is
3688
+ # unavailable rather than silently running unscoped as master.
3689
+ scoped = distinct_query_is_scoped?
3690
+ mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3557
3691
  use_mongo_direct = mongo_direct
3558
- if use_mongo_direct.nil? && has_lookup_stages && defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3559
- use_mongo_direct = true
3692
+
3693
+ if scoped
3694
+ # A scoped aggregation must never reach REST /aggregate (master-key-
3695
+ # only, unenforced) — not even when the caller explicitly passes
3696
+ # mongo_direct: false. Promote to mongo-direct, or fail closed.
3697
+ if mongo_ready
3698
+ use_mongo_direct = true
3699
+ else
3700
+ raise_scoped_aggregation_requires_mongo_direct!
3701
+ end
3702
+ elsif use_mongo_direct.nil?
3703
+ if has_lookup_stages || uses_internal_fields
3704
+ use_mongo_direct = true if mongo_ready
3705
+ end
3560
3706
  end
3561
3707
 
3562
3708
  # Create Aggregation directly to avoid double-applying constraints
3563
- Aggregation.new(self, pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false)
3709
+ Aggregation.new(self, pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false,
3710
+ allow_internal_fields: uses_internal_fields)
3564
3711
  end
3565
3712
 
3566
3713
  private
@@ -3607,6 +3754,16 @@ module Parse
3607
3754
  end
3608
3755
  end
3609
3756
 
3757
+ # Fold in SDK-built aggregation-pipeline marker stages (the _rperm/_wperm
3758
+ # $match emitted by readable_by/publicly_readable/etc., plus set-equality
3759
+ # and empty_or_nil markers). `compile_where` strips these markers, so
3760
+ # without this extraction an ACL filter on `aggregate_from_query` would
3761
+ # be silently dropped — the same omission that affected `Query#count`.
3762
+ markers = compile_markers
3763
+ if markers.key?("__aggregation_pipeline")
3764
+ markers["__aggregation_pipeline"].each { |stage| pipeline << stage }
3765
+ end
3766
+
3610
3767
  # Add $sort stage from order constraints
3611
3768
  unless @order.empty?
3612
3769
  sort_stage = {}
@@ -3702,19 +3859,35 @@ module Parse
3702
3859
  # Parse Server blocks these for security - must use MongoDB direct
3703
3860
  use_mongo_direct = false
3704
3861
 
3862
+ # When the SDK-built pipeline references internal ACL columns
3863
+ # (_rperm/_wperm via readable_by/writable_by/publicly_readable and
3864
+ # friends, or _acl), the mongo-direct sink must be told these
3865
+ # references are sanctioned so the PipelineSecurity internal-fields
3866
+ # denylist lets them through. The pipeline here is built entirely
3867
+ # from SDK constraint translation (no caller-supplied stages), so
3868
+ # this is safe — same posture as results_direct/count_direct.
3869
+ uses_internal_fields = pipeline_uses_internal_fields?(pipeline)
3870
+ scoped = distinct_query_is_scoped?
3871
+
3705
3872
  # Check for explicit mongo_direct preference first
3706
3873
  if defined?(@acl_query_mongo_direct) && !@acl_query_mongo_direct.nil?
3707
3874
  use_mongo_direct = @acl_query_mongo_direct
3708
3875
  elsif defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3709
- # Auto-detect based on pipeline contents
3710
- if has_lookup_stages || pipeline_uses_internal_fields?(pipeline)
3876
+ # Auto-detect based on pipeline contents and query scope
3877
+ if scoped || has_lookup_stages || uses_internal_fields
3711
3878
  use_mongo_direct = true
3712
3879
  end
3880
+ elsif scoped
3881
+ # Same fail-closed contract as #aggregate / #aggregate_from_query:
3882
+ # a scoped pipeline must not fall back to REST /aggregate, which
3883
+ # would drop the scope and return rows the caller cannot read.
3884
+ raise_scoped_aggregation_requires_mongo_direct!
3713
3885
  end
3714
3886
 
3715
3887
  # Create Aggregation directly to avoid double-applying constraints
3716
3888
  # The aggregate() method would redundantly add where constraints again
3717
- Aggregation.new(self, pipeline, verbose: @verbose_aggregate, mongo_direct: use_mongo_direct)
3889
+ Aggregation.new(self, pipeline, verbose: @verbose_aggregate, mongo_direct: use_mongo_direct,
3890
+ allow_internal_fields: uses_internal_fields)
3718
3891
  end
3719
3892
 
3720
3893
  # Check if the pipeline references internal Parse fields that require MongoDB direct access
@@ -5454,23 +5627,38 @@ module Parse
5454
5627
  # Strings are used as-is (user IDs or "role:RoleName" format).
5455
5628
  # Use "public" for public access, "none" or [] for no read permissions.
5456
5629
  #
5457
- # @param permission [Parse::User, Parse::Role, String, Array] the permission to check
5630
+ # @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
5631
+ # the permission to check. A `Parse::User` (or User pointer) expands to
5632
+ # the user's objectId plus every role they inherit; a `Parse::Role` (or
5633
+ # role name String / `:ACL.readable_by_role` form) expands up the role
5634
+ # hierarchy. `"public"` / `:public` / `:everyone` / `:world` map to the
5635
+ # `"*"` wildcard. `"none"` / `:none` / `[]` / `nil` match objects with no
5636
+ # read permissions (explicit empty `_rperm`).
5458
5637
  # @param mongo_direct [Boolean] if true, forces MongoDB direct query. If nil (default),
5459
5638
  # auto-detects based on query complexity. Set to false to force Parse Server aggregation.
5639
+ # @param strict [Boolean] when false (default), the match is **inclusive**:
5640
+ # it ALSO returns publicly-readable rows (`_rperm` contains `"*"`) and
5641
+ # rows with a missing `_rperm` (public by absence), because those are
5642
+ # genuinely readable by the principal. This is access-simulation
5643
+ # semantics ("what can this principal read"). Pass `strict: true` for an
5644
+ # **exact** match — only rows whose `_rperm` literally contains one of
5645
+ # the resolved permissions, with no public/missing rows — which is what
5646
+ # an ownership or security audit wants ("which rows explicitly grant
5647
+ # this principal"). Equivalent to the `:ACL.readable_by_exact` operator.
5460
5648
  # @return [Parse::Query] returns self for method chaining
5461
5649
  # @note This uses MongoDB aggregation pipeline because Parse Server restricts
5462
5650
  # direct queries on internal ACL fields (_rperm/_wperm).
5463
5651
  # @example
5464
- # Song.query.readable_by("user123") # Objects readable by user ID
5465
- # Song.query.readable_by("role:Admin") # Objects readable by Admin role
5466
- # Song.query.readable_by(current_user) # Objects readable by user object
5467
- # Song.query.readable_by("public") # Publicly readable objects
5468
- # Song.query.readable_by("none") # Objects with no read permissions
5469
- # Song.query.readable_by([]) # Objects with no read permissions (empty ACL)
5470
- # Song.query.readable_by([], mongo_direct: true) # Force MongoDB direct query
5471
- def readable_by(permission, mongo_direct: nil)
5652
+ # Song.query.readable_by("user123") # readable by user ID (+ public)
5653
+ # Song.query.readable_by("role:Admin") # readable by Admin role (+ public)
5654
+ # Song.query.readable_by(current_user) # by user object, roles expanded (+ public)
5655
+ # Song.query.readable_by(:public) # publicly readable objects
5656
+ # Song.query.readable_by("none") # objects with no read permissions
5657
+ # Song.query.readable_by([]) # objects with no read permissions (empty ACL)
5658
+ # Song.query.readable_by("role:Admin", strict: true) # ONLY rows that explicitly grant Admin
5659
+ def readable_by(permission, mongo_direct: nil, strict: false)
5472
5660
  @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5473
- where(:ACL.readable_by => permission)
5661
+ where((strict ? :ACL.readable_by_exact : :ACL.readable_by) => permission)
5474
5662
  self
5475
5663
  end
5476
5664
 
@@ -5478,14 +5666,16 @@ module Parse
5478
5666
  #
5479
5667
  # @param role_name [Parse::Role, String, Array] the role name(s) to check
5480
5668
  # @param mongo_direct [Boolean] if true, forces MongoDB direct query.
5669
+ # @param strict [Boolean] when true, exact match only — no implicit public
5670
+ # `"*"` and no missing-`_rperm` rows. See {#readable_by}.
5481
5671
  # @return [Parse::Query] returns self for method chaining
5482
5672
  # @example
5483
5673
  # Song.query.readable_by_role("Admin") # Objects readable by Admin role
5484
5674
  # Song.query.readable_by_role(["Admin", "Editor"]) # Objects readable by Admin or Editor
5485
5675
  # Song.query.readable_by_role(admin_role) # Objects readable by Role object
5486
- def readable_by_role(role_name, mongo_direct: nil)
5676
+ def readable_by_role(role_name, mongo_direct: nil, strict: false)
5487
5677
  @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5488
- where(:ACL.readable_by_role => role_name)
5678
+ where((strict ? :ACL.readable_by_role_exact : :ACL.readable_by_role) => role_name)
5489
5679
  self
5490
5680
  end
5491
5681
 
@@ -5493,23 +5683,27 @@ module Parse
5493
5683
  # Strings are used as-is (user IDs or "role:RoleName" format).
5494
5684
  # Use "public" for public access, "none" or [] for no write permissions.
5495
5685
  #
5496
- # @param permission [Parse::User, Parse::Role, String, Array] the permission to check
5686
+ # @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
5687
+ # the permission to check. See {#readable_by} for value coercion and
5688
+ # role expansion.
5497
5689
  # @param mongo_direct [Boolean] if true, forces MongoDB direct query. If nil (default),
5498
5690
  # auto-detects based on query complexity. Set to false to force Parse Server aggregation.
5691
+ # @param strict [Boolean] when true, exact match only — no implicit public
5692
+ # `"*"` and no missing-`_wperm` rows. See {#readable_by}.
5499
5693
  # @return [Parse::Query] returns self for method chaining
5500
5694
  # @note This uses MongoDB aggregation pipeline because Parse Server restricts
5501
5695
  # direct queries on internal ACL fields (_rperm/_wperm).
5502
5696
  # @example
5503
- # Song.query.writable_by("user123") # Objects writable by user ID
5504
- # Song.query.writable_by("role:Admin") # Objects writable by Admin role
5505
- # Song.query.writable_by(current_user) # Objects writable by user object
5506
- # Song.query.writable_by("public") # Publicly writable objects
5507
- # Song.query.writable_by("none") # Objects with no write permissions
5508
- # Song.query.writable_by([]) # Objects with no write permissions (empty ACL)
5509
- # Song.query.writable_by([], mongo_direct: true) # Force MongoDB direct query
5510
- def writable_by(permission, mongo_direct: nil)
5697
+ # Song.query.writable_by("user123") # writable by user ID (+ public)
5698
+ # Song.query.writable_by("role:Admin") # writable by Admin role (+ public)
5699
+ # Song.query.writable_by(current_user) # by user object, roles expanded (+ public)
5700
+ # Song.query.writable_by(:public) # Publicly writable objects
5701
+ # Song.query.writable_by("none") # objects with no write permissions
5702
+ # Song.query.writable_by([]) # objects with no write permissions (empty ACL)
5703
+ # Song.query.writable_by("role:Admin", strict: true) # ONLY rows that explicitly grant Admin
5704
+ def writable_by(permission, mongo_direct: nil, strict: false)
5511
5705
  @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5512
- where(:ACL.writable_by => permission)
5706
+ where((strict ? :ACL.writable_by_exact : :ACL.writable_by) => permission)
5513
5707
  self
5514
5708
  end
5515
5709
 
@@ -5517,14 +5711,16 @@ module Parse
5517
5711
  #
5518
5712
  # @param role_name [Parse::Role, String, Array] the role name(s) to check
5519
5713
  # @param mongo_direct [Boolean] if true, forces MongoDB direct query.
5714
+ # @param strict [Boolean] when true, exact match only — no implicit public
5715
+ # `"*"` and no missing-`_wperm` rows. See {#readable_by}.
5520
5716
  # @return [Parse::Query] returns self for method chaining
5521
5717
  # @example
5522
5718
  # Song.query.writable_by_role("Admin") # Objects writable by Admin role
5523
5719
  # Song.query.writable_by_role(["Admin", "Editor"]) # Objects writable by Admin or Editor
5524
5720
  # Song.query.writable_by_role(admin_role) # Objects writable by Role object
5525
- def writable_by_role(role_name, mongo_direct: nil)
5721
+ def writable_by_role(role_name, mongo_direct: nil, strict: false)
5526
5722
  @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5527
- where(:ACL.writable_by_role => role_name)
5723
+ where((strict ? :ACL.writable_by_role_exact : :ACL.writable_by_role) => role_name)
5528
5724
  self
5529
5725
  end
5530
5726
 
@@ -5599,6 +5795,38 @@ module Parse
5599
5795
 
5600
5796
  alias_method :master_key_only, :private_acl
5601
5797
 
5798
+ # Find objects that are NOT readable by the given principal — i.e. hidden
5799
+ # from them. Excludes rows readable by the principal directly, via any role
5800
+ # they inherit, OR publicly (a public row is readable by everyone), and
5801
+ # excludes rows with a missing `_rperm` (public by absence).
5802
+ #
5803
+ # @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
5804
+ # the principal to hide from. See {#readable_by} for value coercion.
5805
+ # @param mongo_direct [Boolean] if true, forces MongoDB direct query.
5806
+ # @return [Parse::Query] returns self for method chaining
5807
+ # @example
5808
+ # Song.query.not_readable_by(current_user).results # hidden from this user
5809
+ def not_readable_by(permission, mongo_direct: nil)
5810
+ @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5811
+ where(:ACL.not_readable_by => permission)
5812
+ self
5813
+ end
5814
+
5815
+ # Find objects that are NOT writable by the given principal. See
5816
+ # {#not_readable_by} for the exclusion semantics (direct, role, public).
5817
+ #
5818
+ # @param permission [Parse::User, Parse::Role, Parse::Pointer, String, Symbol, Array]
5819
+ # the principal to exclude. See {#readable_by} for value coercion.
5820
+ # @param mongo_direct [Boolean] if true, forces MongoDB direct query.
5821
+ # @return [Parse::Query] returns self for method chaining
5822
+ # @example
5823
+ # Song.query.not_writable_by("role:Admin").results
5824
+ def not_writable_by(permission, mongo_direct: nil)
5825
+ @acl_query_mongo_direct = mongo_direct unless mongo_direct.nil?
5826
+ where(:ACL.not_writable_by => permission)
5827
+ self
5828
+ end
5829
+
5602
5830
  # Find objects that are NOT publicly readable.
5603
5831
  # Matches objects where _rperm does NOT contain "*".
5604
5832
  #
@@ -5728,8 +5956,19 @@ module Parse
5728
5956
  # aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
5729
5957
  # @param raw_field_names [Boolean] when true, passes +rawFieldNames: true+ to the Parse Server
5730
5958
  # REST aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
5959
+ # @param allow_internal_fields [Boolean] when true, the mongo-direct path
5960
+ # forwards +allow_internal_fields: true+ to {Parse::MongoDB.aggregate} so
5961
+ # SDK-built ACL `$match` stages that legitimately reference +_rperm+ /
5962
+ # +_wperm+ (emitted by {Parse::Query#readable_by}, +#publicly_readable+,
5963
+ # and friends) pass the pipeline-security internal-fields denylist —
5964
+ # matching the parity already held by +results_direct+ / +count_direct+ /
5965
+ # +distinct_direct+. Set +true+ ONLY when this Aggregation's pipeline was
5966
+ # built entirely from SDK constraint translation (no caller-supplied
5967
+ # stages); the credential-field guard (`_hashed_password`, session tokens,
5968
+ # auth data) is what +allow_internal_fields+ relaxes, so it must never be
5969
+ # set on a pipeline that interpolates user input. Defaults to +false+.
5731
5970
  def initialize(query, pipeline, verbose: nil, mongo_direct: false, max_time_ms: nil,
5732
- raw_values: false, raw_field_names: false)
5971
+ raw_values: false, raw_field_names: false, allow_internal_fields: false)
5733
5972
  @query = query
5734
5973
  @pipeline = pipeline
5735
5974
  @cached_response = nil
@@ -5737,6 +5976,7 @@ module Parse
5737
5976
  @max_time_ms = max_time_ms
5738
5977
  @raw_values = raw_values
5739
5978
  @raw_field_names = raw_field_names
5979
+ @allow_internal_fields = allow_internal_fields
5740
5980
  # Use provided verbose setting, or fall back to query's verbose_aggregate setting
5741
5981
  @verbose = verbose.nil? ? @query.instance_variable_get(:@verbose_aggregate) : verbose
5742
5982
  end
@@ -5789,7 +6029,8 @@ module Parse
5789
6029
  # honors it on the mongo-direct path too (parity with results_direct /
5790
6030
  # count_direct / distinct_direct).
5791
6031
  hint = @query.instance_variable_get(:@hint)
5792
- Parse::MongoDB.aggregate(table, @pipeline, max_time_ms: max_time_ms, hint: hint, **auth_kwargs)
6032
+ Parse::MongoDB.aggregate(table, @pipeline, max_time_ms: max_time_ms, hint: hint,
6033
+ allow_internal_fields: @allow_internal_fields, **auth_kwargs)
5793
6034
  end
5794
6035
 
5795
6036
  # Returns processed results from the aggregation.
@@ -108,20 +108,27 @@ module Parse
108
108
  score_quantize = (agent.permissions != :admin)
109
109
  vector_field = Parse::Agent::MetadataRegistry.searchable_field(cname)
110
110
 
111
- chunks = Parse::Retrieval.retrieve(
112
- query: query,
113
- klass: klass,
114
- field: vector_field,
115
- text_field: resolved_text_field,
116
- k: clamp_k(k),
117
- filter: filter,
118
- vector_filter: vector_filter,
119
- chunker: build_chunker(chunk_size, chunk_overlap, chunk_by, max_chunks_per_document),
120
- tenant_scope: scope,
121
- score_quantize: score_quantize,
122
- source_transform: source_projector(agent, cname, scope),
123
- **agent.acl_scope_kwargs,
124
- )
111
+ # with_precharged: the cap was charged above with per-tenant
112
+ # identity (or deliberately skipped for trusted admin agents) —
113
+ # suppress the generic query-embed charge inside
114
+ # find_similar/embed_query_text! so the query isn't double-billed
115
+ # (or admin queries billed to the shared default bucket).
116
+ chunks = Parse::Embeddings::SpendCap.with_precharged do
117
+ Parse::Retrieval.retrieve(
118
+ query: query,
119
+ klass: klass,
120
+ field: vector_field,
121
+ text_field: resolved_text_field,
122
+ k: clamp_k(k),
123
+ filter: filter,
124
+ vector_filter: vector_filter,
125
+ chunker: build_chunker(chunk_size, chunk_overlap, chunk_by, max_chunks_per_document),
126
+ tenant_scope: scope,
127
+ score_quantize: score_quantize,
128
+ source_transform: source_projector(agent, cname, scope),
129
+ **agent.acl_scope_kwargs,
130
+ )
131
+ end
125
132
 
126
133
  # Token budget (B4): trim the score-ordered chunk list before
127
134
  # building the envelope so `documents` only carries parents whose
@@ -70,6 +70,84 @@ module Parse
70
70
  obj
71
71
  end
72
72
 
73
+ # Translate Parse pointer VALUES in a caller-supplied filter into
74
+ # their MongoDB storage form so they actually match raw documents.
75
+ # `{ owner: <Parse::Pointer User/abc> }` becomes
76
+ # `{ "_p_owner" => "_User$abc" }` — pointer columns are stored under
77
+ # a `_p_` prefix with `"<className>$<objectId>"` string values, so a
78
+ # Parse-side pointer (a `{__type: "Pointer", ...}` hash on the wire,
79
+ # or a `Parse::Pointer` / `Parse::Object` instance from Ruby
80
+ # callers) in a `$match` / `$vectorSearch.filter` would otherwise
81
+ # never match anything.
82
+ #
83
+ # Recognized pointer values:
84
+ # * `Parse::Pointer` / `Parse::Object` instances,
85
+ # * `{ "__type" => "Pointer", "className" => ..., "objectId" => ... }`
86
+ # hashes (symbol or string keys).
87
+ #
88
+ # Translation applies to direct values, and to pointer values inside
89
+ # one level of operator hashes (`{ owner: { "$in" => [ptr, ptr] } }`,
90
+ # `$eq` / `$ne` / `$nin`). Non-pointer values and unrecognized keys
91
+ # pass through untouched, so the call is idempotent.
92
+ #
93
+ # SECURITY ORDERING: run this AFTER {.assert_no_underscore_keys!} /
94
+ # the agent filter-field allowlist (callers may not name `_p_*`
95
+ # columns directly) and BEFORE the tenant-scope fold.
96
+ #
97
+ # @param klass [Class] the Parse::Object subclass (for field_map
98
+ # wire-name resolution).
99
+ # @param filter [Hash, nil] caller filter.
100
+ # @return [Hash, nil] translated copy (or the input when nothing
101
+ # needed translation / input was nil).
102
+ def translate_pointer_filter_values(klass, filter)
103
+ return filter unless filter.is_a?(Hash)
104
+ out = {}
105
+ filter.each do |key, value|
106
+ if (storage = pointer_storage_value(value))
107
+ out["_p_#{wire_name(klass, key)}"] = storage
108
+ elsif value.is_a?(Hash) && value.keys.any? { |op| op.to_s.start_with?("$") }
109
+ translated = value.transform_values do |opval|
110
+ if (s = pointer_storage_value(opval))
111
+ s
112
+ elsif opval.is_a?(Array)
113
+ opval.map { |el| pointer_storage_value(el) || el }
114
+ else
115
+ opval
116
+ end
117
+ end
118
+ if translated == value
119
+ out[key] = value
120
+ else
121
+ out["_p_#{wire_name(klass, key)}"] = translated
122
+ end
123
+ else
124
+ out[key] = value
125
+ end
126
+ end
127
+ out
128
+ end
129
+
130
+ # @!visibility private
131
+ # `"<className>$<objectId>"` storage string for a pointer-shaped
132
+ # value, or nil when the value is not a pointer.
133
+ def pointer_storage_value(value)
134
+ if defined?(Parse::Pointer) && value.is_a?(Parse::Pointer)
135
+ cname = value.parse_class
136
+ oid = value.id
137
+ return nil if cname.to_s.empty? || oid.to_s.empty?
138
+ return "#{cname}$#{oid}"
139
+ end
140
+ if value.is_a?(Hash)
141
+ type = value["__type"] || value[:__type]
142
+ return nil unless type.to_s == "Pointer"
143
+ cname = value["className"] || value[:className]
144
+ oid = value["objectId"] || value[:objectId]
145
+ return nil if cname.to_s.empty? || oid.to_s.empty?
146
+ return "#{cname}$#{oid}"
147
+ end
148
+ nil
149
+ end
150
+
73
151
  # Retrieve and chunk documents semantically similar to `query`.
74
152
  #
75
153
  # @param query [String] natural-language query.
@@ -136,6 +214,12 @@ module Parse
136
214
  end
137
215
 
138
216
  resolved_text_field = (text_field || infer_text_field!(klass)).to_sym
217
+ # Pointer-value translation runs BEFORE the tenant-scope fold (the
218
+ # fold's conflict check must see final storage-form keys) and after
219
+ # any caller-side underscore-key gate (the agent tool walks the raw
220
+ # filter before calling retrieve).
221
+ filter = translate_pointer_filter_values(klass, filter)
222
+ vector_filter = translate_pointer_filter_values(klass, vector_filter)
139
223
  merged_vector_filter = fold_tenant_scope(klass, vector_filter, tenant_scope)
140
224
  chunker ||= default_chunker
141
225
  text_wire = wire_name(klass, resolved_text_field)