parse-stack-next 4.5.0 → 5.0.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
data/lib/parse/agent.rb CHANGED
@@ -455,6 +455,37 @@ module Parse
455
455
  WRITE_GATED_TOOLS = %i[create_object update_object delete_object].freeze
456
456
  SCHEMA_GATED_TOOLS = %i[create_class delete_class].freeze
457
457
 
458
+ # Built-in tools that are safe to dispatch when the agent runs on a
459
+ # client (no master_key) with a session_token. Parse Server natively
460
+ # enforces ACL + CLP + protectedFields on these REST endpoints, so the
461
+ # SDK does not need to add an enforcement layer for them.
462
+ #
463
+ # The list is the MODE CEILING in client mode: an operator's `tools:`
464
+ # filter may narrow further, but cannot widen past this set. Anything
465
+ # not in CLIENT_SAFE_READ_TOOLS or CLIENT_SAFE_MUTATION_TOOLS is
466
+ # refused at dispatch when @client_mode is true, including custom
467
+ # registered tools (which must opt in explicitly via
468
+ # `Parse::Agent::Tools.register(client_safe: true, ...)`).
469
+ CLIENT_SAFE_READ_TOOLS = %i[
470
+ list_tools
471
+ get_object
472
+ get_objects
473
+ query_class
474
+ count_objects
475
+ get_sample_objects
476
+ ].freeze
477
+
478
+ # Built-in mutation tools that route through session-token REST and
479
+ # are therefore enforceable by Parse Server's native ACL/CLP. Gated
480
+ # additionally by the per-agent +allow_mutations:+ kwarg in client
481
+ # mode (default false) and by the existing process-level env vars
482
+ # (PARSE_AGENT_ALLOW_WRITE_TOOLS + PARSE_AGENT_ALLOW_RAW_CRUD).
483
+ CLIENT_SAFE_MUTATION_TOOLS = %i[
484
+ create_object
485
+ update_object
486
+ delete_object
487
+ ].freeze
488
+
458
489
  # Truthy ENV-var values. Anything else (including unset) means disabled.
459
490
  ENV_TRUTHY_RE = /\A(1|true|yes|on)\z/i.freeze
460
491
 
@@ -590,6 +621,24 @@ module Parse
590
621
  # @return [Parse::Client] the Parse client instance to use
591
622
  attr_reader :client
592
623
 
624
+ # @return [Boolean] whether the agent runs in client mode (its
625
+ # Parse::Client has no master_key). In client mode the dispatchable
626
+ # tool set is restricted to {CLIENT_SAFE_READ_TOOLS},
627
+ # {CLIENT_SAFE_MUTATION_TOOLS} (gated on {#allow_mutations?}), and
628
+ # any registered tool declared +client_safe: true+.
629
+ def client_mode?
630
+ @client_mode == true
631
+ end
632
+
633
+ # @return [Boolean] whether this agent may dispatch raw mutation
634
+ # tools (create_object/update_object/delete_object). Layered with
635
+ # the process-level PARSE_AGENT_ALLOW_WRITE_TOOLS +
636
+ # PARSE_AGENT_ALLOW_RAW_CRUD env vars (all three must be true).
637
+ # Default: +false+ in client mode, +true+ in master-key mode.
638
+ def allow_mutations?
639
+ @allow_mutations == true
640
+ end
641
+
593
642
  # @return [Array<Hash>] log of operations performed in this session
594
643
  attr_reader :operation_log
595
644
 
@@ -604,7 +653,7 @@ module Parse
604
653
 
605
654
  # @return [String, nil] caller-supplied identifier that ties multiple
606
655
  # tool calls into a single logical conversation. Set by the transport
607
- # layer (MCPRackApp reads X-MCP-Session-Id) or directly by an
656
+ # layer (MCPRackApp reads Mcp-Session-Id) or directly by an
608
657
  # embedder. Included in every `parse.agent.tool_call` notification
609
658
  # payload as `:correlation_id` when present. Sanitized to a max of
610
659
  # 128 characters from the set `[A-Za-z0-9._-]` to prevent log
@@ -1068,7 +1117,8 @@ module Parse
1068
1117
  tools: nil, methods: nil, classes: nil, filters: nil,
1069
1118
  parent: nil, recursion_depth: nil,
1070
1119
  strict_tool_filter: nil, strict_class_filter: nil,
1071
- master_atlas: nil)
1120
+ master_atlas: nil,
1121
+ allow_mutations: nil)
1072
1122
  # SECURITY: Mutually exclusive identity inputs. `acl_user:` and
1073
1123
  # `acl_role:` are unverified constructor assertions (the SDK does
1074
1124
  # not round-trip them to Parse Server for validation the way
@@ -1271,6 +1321,78 @@ module Parse
1271
1321
  @tenant_id = tenant_id
1272
1322
  @master_atlas = master_atlas == true
1273
1323
 
1324
+ # Client-mode detection. An agent runs in CLIENT MODE when its
1325
+ # underlying Parse::Client has no master_key AND it was constructed
1326
+ # with a non-empty session_token. This is the explicit
1327
+ # "session-token-on-a-public-client" posture: every tool call must
1328
+ # route through a REST endpoint Parse Server natively authorizes
1329
+ # (ACL + CLP + protectedFields) because the SDK has no master-key
1330
+ # fallback to lean on.
1331
+ #
1332
+ # The "no master_key, no session_token" case is NOT treated as
1333
+ # client mode — that's a misconfigured master-key-posture agent
1334
+ # whose REST calls will fail with 401 at dispatch. The existing
1335
+ # one-time master-key warning surfaces this; refusing here would
1336
+ # break compatibility with test harnesses and bootstrap factories
1337
+ # that construct agents before identity is threaded in.
1338
+ #
1339
+ # acl_user / acl_role on a no-master-key client are refused
1340
+ # regardless of session_token presence: they are unverified
1341
+ # constructor assertions with no REST equivalent — Parse Server's
1342
+ # REST surface offers no "act as user-pointer" affordance, so the
1343
+ # SDK cannot honor them without a master key.
1344
+ no_master_key = @client.respond_to?(:master_key) && @client.master_key.nil?
1345
+ session_token_present = !@session_token.nil? && !@session_token.to_s.empty?
1346
+ @client_mode = no_master_key && session_token_present
1347
+
1348
+ if no_master_key && (@acl_user_scope || @acl_role_scope)
1349
+ raise ArgumentError,
1350
+ "Parse::Agent: acl_user: and acl_role: require a Parse::Client " \
1351
+ "with a master_key (they are unverified constructor assertions " \
1352
+ "the SDK can only honor via master-key REST). The supplied " \
1353
+ "client has no master_key. Use session_token: instead, or " \
1354
+ "switch to a master-key client."
1355
+ end
1356
+
1357
+ # Per-agent mutation gate. Layered ON TOP of the process-level
1358
+ # PARSE_AGENT_ALLOW_WRITE_TOOLS / PARSE_AGENT_ALLOW_RAW_CRUD env
1359
+ # vars — BOTH must be true for raw create/update/delete to
1360
+ # dispatch. Defaults:
1361
+ # * Client mode → false (default-deny; opt in per agent)
1362
+ # * Master-key → true (back-compat; existing operators have
1363
+ # only the env vars today, and adding a
1364
+ # false default would silently disable
1365
+ # writes for every existing master-key
1366
+ # agent).
1367
+ # When +parent:+ is supplied, the child cannot widen the parent's
1368
+ # gate: if parent.allow_mutations? is false, child must also be
1369
+ # false. Default-on-nil inherits the parent's value verbatim so
1370
+ # the safe path (omit kwarg) is trivially correct.
1371
+ if parent
1372
+ parent_allows = parent.respond_to?(:allow_mutations?) ? parent.allow_mutations? : true
1373
+ resolved_allow_mutations =
1374
+ if allow_mutations.nil?
1375
+ parent_allows
1376
+ else
1377
+ allow_mutations == true
1378
+ end
1379
+ if resolved_allow_mutations && !parent_allows
1380
+ raise ArgumentError,
1381
+ "sub-agent allow_mutations: true exceeds parent's " \
1382
+ "allow_mutations: false. A sub-agent cannot widen the " \
1383
+ "parent's mutation gate — drop the override (omit to inherit) " \
1384
+ "or pass allow_mutations: false explicitly."
1385
+ end
1386
+ @allow_mutations = resolved_allow_mutations
1387
+ else
1388
+ @allow_mutations =
1389
+ if allow_mutations.nil?
1390
+ !@client_mode
1391
+ else
1392
+ allow_mutations == true
1393
+ end
1394
+ end
1395
+
1274
1396
  # Resolve the ACL scope ONCE at construction into a frozen
1275
1397
  # Parse::ACLScope::Resolution. Three modes:
1276
1398
  #
@@ -1561,12 +1683,24 @@ module Parse
1561
1683
  #
1562
1684
  # Resolution order is strict: builtin permission-tier tools are unioned
1563
1685
  # with registered tools whose declared permission is <= the agent's
1564
- # tier, then the per-instance filter narrows that set. The filter
1565
- # cannot elevate above the permission-tier output `tools: { only:
1686
+ # tier, then the per-instance filter narrows that set, then in client
1687
+ # mode the client-safe ceiling narrows it further. None of these
1688
+ # steps can elevate above its input — `tools: { only:
1566
1689
  # [:delete_object] }` on a `:readonly` agent still excludes
1567
- # `delete_object`. This invariant is the structural correctness of
1568
- # the layered design (env-gates permission tier ▷ per-instance
1569
- # filter) and must not be violated by future changes.
1690
+ # `delete_object`, and `tools: { only: [:aggregate] }` on a
1691
+ # client-mode agent still excludes `aggregate`. This invariant is
1692
+ # the structural correctness of the layered design (mode ceiling ▷
1693
+ # env-gates ▷ permission tier ▷ per-instance filter) and must not
1694
+ # be violated by future changes.
1695
+ #
1696
+ # The client-mode intersection here is what makes the advertised
1697
+ # catalog (MCP `tools/list`, OpenAI function definitions, the
1698
+ # describe output) match the set the dispatch path will actually
1699
+ # dispatch. Without it, an LLM would see a refused tool in its
1700
+ # catalog, attempt it, and learn about the refusal only via an
1701
+ # access-denied error — wasting turns on tools it never could have
1702
+ # called. The dispatch-path gate in {#execute} remains as the
1703
+ # belt-and-suspenders enforcement point.
1570
1704
  #
1571
1705
  # @return [Array<Symbol>] list of allowed tool names
1572
1706
  def allowed_tools
@@ -1575,6 +1709,14 @@ module Parse
1575
1709
 
1576
1710
  permitted = permitted & @tool_filter_only.to_a if @tool_filter_only
1577
1711
  permitted = permitted - @tool_filter_except.to_a if @tool_filter_except
1712
+
1713
+ if @client_mode
1714
+ permitted = permitted.select { |sym| Parse::Agent::Tools.client_safe?(sym) }
1715
+ unless @allow_mutations
1716
+ permitted -= Parse::Agent::CLIENT_SAFE_MUTATION_TOOLS
1717
+ end
1718
+ end
1719
+
1578
1720
  permitted
1579
1721
  end
1580
1722
 
@@ -1692,10 +1834,64 @@ module Parse
1692
1834
  end
1693
1835
 
1694
1836
  unless tool_allowed?(tool_name)
1695
- # Distinguish "filter excluded it" (tier permits, instance filter
1696
- # narrowed it away) from "tier never allowed it" so consumers see
1697
- # the meaningful diagnostic. Same denial outcome either way — only
1698
- # the error_code + message differ.
1837
+ # Distinguish refusal reasons so the LLM (and SOC tooling) see
1838
+ # the meaningful diagnostic. Resolution order matters the
1839
+ # client-mode ceiling and the per-agent mutation gate emit
1840
+ # specific :access_denied messages so an operator can tell
1841
+ # which knob refused the call. The generic "filter excluded
1842
+ # it" / "tier never allowed it" branches catch what's left.
1843
+
1844
+ # Operator-filter precedence: when the per-instance `tools:`
1845
+ # filter is the binding gate, prefer the filter message even
1846
+ # if the client-mode ceiling or mutation gate would also have
1847
+ # refused. Otherwise an operator who set
1848
+ # `tools: { except: [:create_object] }` AND `allow_mutations:
1849
+ # false` is told "set allow_mutations: true", which won't
1850
+ # actually help — the filter is the real blocker.
1851
+ operator_filter_excludes =
1852
+ (@tool_filter_except && @tool_filter_except.include?(tool_name)) ||
1853
+ (@tool_filter_only && !@tool_filter_only.include?(tool_name))
1854
+ if operator_filter_excludes && tier_permits_tool?(tool_name)
1855
+ return error_response(
1856
+ "Tool '#{tool_name}' is not enabled for this agent instance " \
1857
+ "(excluded by the configured tools: filter).",
1858
+ error_code: :tool_filtered,
1859
+ )
1860
+ end
1861
+
1862
+ if @client_mode &&
1863
+ Parse::Agent::CLIENT_SAFE_MUTATION_TOOLS.include?(tool_name) &&
1864
+ !@allow_mutations &&
1865
+ Parse::Agent::Tools.client_safe?(tool_name)
1866
+ # The tool is REST-safe (the mode ceiling would let it
1867
+ # through) but the per-agent mutation gate is closed.
1868
+ # Naming the gate specifically avoids sending operators to
1869
+ # the env-var rabbit hole when the real fix is the
1870
+ # constructor kwarg.
1871
+ return error_response(
1872
+ "Raw mutation tool '#{tool_name}' is disabled for this " \
1873
+ "client-mode agent. Construct the agent with " \
1874
+ "allow_mutations: true to enable write/delete dispatch. " \
1875
+ "The process-level PARSE_AGENT_ALLOW_WRITE_TOOLS / " \
1876
+ "PARSE_AGENT_ALLOW_RAW_CRUD env vars must additionally " \
1877
+ "be set on the deployment.",
1878
+ error_code: :access_denied,
1879
+ )
1880
+ end
1881
+ if @client_mode && !Parse::Agent::Tools.client_safe?(tool_name)
1882
+ # Mode ceiling. Tool requires either master-key REST or
1883
+ # mongo-direct, neither of which a client-mode agent has.
1884
+ # Refuse with a specific message so the LLM doesn't retry.
1885
+ return error_response(
1886
+ "Tool '#{tool_name}' is not available to client-mode agents. " \
1887
+ "Client mode (no master_key on the underlying Parse::Client) " \
1888
+ "restricts dispatch to session-token-authorized REST tools: " \
1889
+ "#{(CLIENT_SAFE_READ_TOOLS + CLIENT_SAFE_MUTATION_TOOLS).sort.join(", ")}, " \
1890
+ "plus any custom tool registered with client_safe: true. " \
1891
+ "Refused at the mode ceiling.",
1892
+ error_code: :access_denied,
1893
+ )
1894
+ end
1699
1895
  if tier_permits_tool?(tool_name)
1700
1896
  return error_response(
1701
1897
  "Tool '#{tool_name}' is not enabled for this agent instance " \
@@ -1724,10 +1920,11 @@ module Parse
1724
1920
  # also re-opening the generic create_object/update_object surface
1725
1921
  # (which additionally requires RAW_CRUD=true).
1726
1922
  if WRITE_GATED_TOOLS.include?(tool_name) &&
1727
- !(Parse::Agent.write_tools_enabled? && Parse::Agent.raw_crud_enabled?)
1923
+ !(Parse::Agent.write_tools_enabled? && Parse::Agent.raw_crud_enabled? && @allow_mutations)
1728
1924
  missing = []
1729
1925
  missing << "PARSE_AGENT_ALLOW_WRITE_TOOLS=true" unless Parse::Agent.write_tools_enabled?
1730
1926
  missing << "PARSE_AGENT_ALLOW_RAW_CRUD=true" unless Parse::Agent.raw_crud_enabled?
1927
+ missing << "allow_mutations: true (per-agent kwarg)" unless @allow_mutations
1731
1928
  return error_response(
1732
1929
  "Raw CRUD tool '#{tool_name}' is disabled. Required: #{missing.join(' AND ')}. " \
1733
1930
  "Prefer declaring an agent_method on the target class for an intent-based " \
@@ -6,12 +6,34 @@ module Parse
6
6
  # Defines the Analytics interface for the Parse REST API
7
7
  module Analytics
8
8
 
9
- # Send analytics data.
10
- # @param event_name [String] the name of the event.
11
- # @param metrics [Hash] the metrics to attach to event.
9
+ # Send analytics data. Parse Server's default `analyticsAdapter`
10
+ # is a no-op: events POSTed here are accepted but not persisted
11
+ # and cannot be read back through Parse Server. Operators who wire
12
+ # in a custom adapter decide what (if anything) to do with each
13
+ # event, including whether to cap dimension count — the legacy
14
+ # parse.com eight-pair cap does NOT apply to Parse Server out of
15
+ # the box. If you need to read events back, persist them to a
16
+ # regular `Parse::Object` subclass instead.
17
+ #
18
+ # @param event_name [String] the name of the event. Restricted to
19
+ # word characters, hyphens, and dots so the value cannot escape
20
+ # the `/events/` path segment.
21
+ # @param metrics [Hash] dimension pairs to attach to the event.
22
+ # @param opts [Hash] additional options forwarded to {Parse::Client#request}
23
+ # (e.g. :session_token, :use_master_key). Analytics events are
24
+ # typically public-writable, but a session token can be threaded
25
+ # through for installations that require authentication on /events.
26
+ # @raise [ArgumentError] when `event_name` is empty or contains
27
+ # characters outside `[\w\-\.]`.
12
28
  # @see http://docs.parseplatform.org/rest/guide/#analytics Parse Analytics
13
- def send_analytics(event_name, metrics = {})
14
- request :post, "events/#{event_name}", body: metrics
29
+ def send_analytics(event_name, metrics = {}, **opts)
30
+ safe = event_name.to_s
31
+ unless safe.match?(/\A[\w\-\.]+\z/)
32
+ raise ArgumentError,
33
+ "Parse::API::Analytics#send_analytics: event_name must contain only " \
34
+ "word characters, hyphens, or dots (got #{event_name.inspect})"
35
+ end
36
+ request :post, "events/#{safe}", body: metrics, opts: opts
15
37
  end
16
38
  end
17
39
  end
@@ -15,12 +15,16 @@ module Parse
15
15
  # @param fileName [String] the basename of the file.
16
16
  # @param data [Hash] the data related to this file.
17
17
  # @param content_type [String] the mime-type of the file.
18
+ # @param opts [Hash] additional options forwarded to {Parse::Client#request}
19
+ # (e.g. :session_token, :use_master_key). When the SDK is running in
20
+ # client mode against a Parse Server with `fileUpload.enableForAuthenticatedUser`
21
+ # on, a session_token is required for the upload to be accepted.
18
22
  # @return [Parse::Response]
19
- def create_file(fileName, data = {}, content_type = nil)
23
+ def create_file(fileName, data = {}, content_type = nil, **opts)
20
24
  safe = Parse::API::PathSegment.file!(fileName, kind: "file name")
21
25
  headers = {}
22
26
  headers.merge!({ Parse::Protocol::CONTENT_TYPE => content_type.to_s }) if content_type.present?
23
- response = request :post, "#{FILES_PATH}/#{safe}", body: data, headers: headers
27
+ response = request :post, "#{FILES_PATH}/#{safe}", body: data, headers: headers, opts: opts
24
28
  response.parse_class = Parse::Model::TYPE_FILE
25
29
  response
26
30
  end
@@ -8,12 +8,29 @@ module Parse
8
8
  # @!visibility private
9
9
  PUSH_PATH = "push"
10
10
 
11
- # Update the schema for a collection.
12
- # @param payload [Hash] the paylod for the Push notification.
11
+ # Send a Push notification.
12
+ #
13
+ # Parse Server's `POST /parse/push` endpoint is master-key-only —
14
+ # there is no session-token authorization model for sending pushes,
15
+ # and a no-master-key client cannot use this method. Calling it
16
+ # without master-key credentials returns HTTP 403 from the server;
17
+ # this guard fails closed in the SDK so the deployment's
18
+ # configuration isn't the only line of defense.
19
+ #
20
+ # @param payload [Hash] the payload for the Push notification.
21
+ # @param headers [Hash] additional HTTP headers to send with the request.
22
+ # @param opts [Hash] additional options to pass to the {Parse::Client} request.
13
23
  # @return [Parse::Response]
24
+ # @raise [Parse::Error::AuthenticationError] when the client has no master key configured.
14
25
  # @see http://docs.parseplatform.org/rest/guide/#sending-pushes Sending Pushes
15
- def push(payload = {})
16
- request :post, PUSH_PATH, body: payload.as_json
26
+ def push(payload = {}, headers: {}, **opts)
27
+ unless master_key.is_a?(String) && !master_key.empty?
28
+ raise Parse::Error::AuthenticationError,
29
+ "Parse::API::Push#push requires a master key — push notifications " \
30
+ "have no session-token authorization model in Parse Server"
31
+ end
32
+ opts[:use_master_key] = true unless opts.key?(:use_master_key)
33
+ request :post, PUSH_PATH, body: payload.as_json, headers: headers, opts: opts
17
34
  end
18
35
  end
19
36
  end
@@ -14,6 +14,16 @@ module Parse
14
14
  SERVER_INFO_PATH = "serverInfo"
15
15
  # @!visibility private
16
16
  SERVER_HEALTH_PATH = "health"
17
+
18
+ # Minimum supported Parse Server major version. Below this floor the
19
+ # SDK emits a one-shot deprecation warning per client instance the
20
+ # first time `server_info` resolves. The threshold tracks "current
21
+ # major minus two" against Parse Server's release cadence — Parse
22
+ # Server 9.x is current in 2026, so anything below 7.0 is flagged.
23
+ # Override with `PARSE_DEPRECATED_SERVER_VERSION_BELOW=6.0` or
24
+ # silence entirely with `PARSE_SUPPRESS_SERVER_VERSION_WARNING=true`.
25
+ DEPRECATED_SERVER_VERSION_BELOW = "7.0.0"
26
+
17
27
  # Fetch and cache information about the Parse server configuration. This
18
28
  # hash contains information specifically to the configuration of the running
19
29
  # parse server.
@@ -23,6 +33,8 @@ module Parse
23
33
  response = request :get, SERVER_INFO_PATH
24
34
  @server_info = response.error? ? nil :
25
35
  response.result.with_indifferent_access
36
+ warn_if_deprecated_server_version! if @server_info
37
+ @server_info
26
38
  end
27
39
 
28
40
  # Fetches the status of the server based on the health check.
@@ -37,6 +49,7 @@ module Parse
37
49
  # @return [Hash] a hash containing server configuration if available.
38
50
  def server_info!
39
51
  @server_info = nil
52
+ @server_version_warned = false
40
53
  server_info
41
54
  end
42
55
 
@@ -45,6 +58,52 @@ module Parse
45
58
  def server_version
46
59
  server_info.present? ? @server_info[:parseServerVersion] : nil
47
60
  end
61
+
62
+ private
63
+
64
+ # One-shot deprecation warning. The check runs once per client
65
+ # instance (`@server_version_warned` latches), after `server_info`
66
+ # has actually resolved against the wire — so unit tests that
67
+ # never reach a real server don't pay the cost. Silenceable via
68
+ # ENV so operators on a known-old Parse Server pinned for an
69
+ # explicit reason can suppress the noise.
70
+ def warn_if_deprecated_server_version!
71
+ return if @server_version_warned
72
+ return if ENV["PARSE_SUPPRESS_SERVER_VERSION_WARNING"] == "true"
73
+ return unless defined?(Parse) && (!Parse.respond_to?(:suppress_server_version_warning?) || !Parse.suppress_server_version_warning?)
74
+ version_string = @server_info[:parseServerVersion].to_s
75
+ return if version_string.empty?
76
+ floor = ENV["PARSE_DEPRECATED_SERVER_VERSION_BELOW"].to_s
77
+ floor = DEPRECATED_SERVER_VERSION_BELOW if floor.empty?
78
+ return unless server_version_below?(version_string, floor)
79
+ @server_version_warned = true
80
+ message = "[Parse::Client] DEPRECATION: connected Parse Server version #{version_string} " \
81
+ "is below the supported floor #{floor}. Newer Parse Stack releases assume " \
82
+ "behaviors (CLP shape, aggregate envelope, $vectorSearch, schema endpoints) " \
83
+ "that may not be present on this server. Upgrade Parse Server, or silence " \
84
+ "with Parse.suppress_server_version_warning = true / " \
85
+ "PARSE_SUPPRESS_SERVER_VERSION_WARNING=true. Override the floor with " \
86
+ "PARSE_DEPRECATED_SERVER_VERSION_BELOW=#{floor.sub(/\.0\.0\z/, ".0")}."
87
+ if defined?(Parse) && Parse.respond_to?(:logger) && Parse.logger
88
+ Parse.logger.warn(message)
89
+ else
90
+ warn message
91
+ end
92
+ end
93
+
94
+ # Loose semver compare on major.minor. Parse Server publishes
95
+ # `parseServerVersion` as `"9.0.0"`, `"6.5.7"`, `"8.0.0-alpha.1"`,
96
+ # etc. We only care about the major (and minor as a tiebreak) for
97
+ # the deprecation gate. Falls back to "not below" on any
98
+ # unparseable input so a wire-format surprise never raises.
99
+ def server_version_below?(actual, floor)
100
+ actual_parts = actual.scan(/\d+/).first(2).map(&:to_i)
101
+ floor_parts = floor.scan(/\d+/).first(2).map(&:to_i)
102
+ return false if actual_parts.empty? || floor_parts.empty?
103
+ actual_parts << 0 while actual_parts.length < 2
104
+ floor_parts << 0 while floor_parts.length < 2
105
+ (actual_parts <=> floor_parts) < 0
106
+ end
48
107
  end
49
108
  end
50
109
  end
@@ -37,6 +37,7 @@ module Parse
37
37
  end
38
38
 
39
39
  # Find user matching this active session token.
40
+ # @param session_token [String] the Parse user session token to look up.
40
41
  # @param opts [Hash] additional options to pass to the {Parse::Client} request.
41
42
  # @param headers [Hash] additional HTTP headers to send with the request.
42
43
  # @return [Parse::Response]
@@ -69,7 +70,7 @@ module Parse
69
70
  # @param headers [Hash] additional HTTP headers to send with the request.
70
71
  # @return [Parse::Response]
71
72
  def update_user(id, body = {}, headers: {}, **opts)
72
- response = request :put, "#{USER_PATH_PREFIX}/#{id}", body: body, opts: opts
73
+ response = request :put, "#{USER_PATH_PREFIX}/#{id}", body: body, headers: headers, opts: opts
73
74
  response.parse_class = Parse::Model::CLASS_USER
74
75
  response
75
76
  end
@@ -97,13 +98,36 @@ module Parse
97
98
  end
98
99
 
99
100
  # Request a password reset for a registered email.
101
+ #
102
+ # Client-side rate limited on a per-email basis using the same
103
+ # tracker that backs {#login} (entries are namespaced under a
104
+ # +pwreset:+ prefix so the two limiters don't collide on usernames
105
+ # that happen to equal an email). Every request counts toward the
106
+ # backoff — Parse Server's +requestPasswordReset+ response does
107
+ # not differentiate "email exists" from "email does not exist"
108
+ # (and rightly so, to avoid account enumeration), so the SDK
109
+ # cannot distinguish a legitimate retry from an attacker probing
110
+ # for valid emails. The cap mirrors {LOGIN_MAX_FAILURES}: 5
111
+ # requests within the rolling window before exponential backoff
112
+ # kicks in and the limit clears via the same TTL-based cleanup.
113
+ #
100
114
  # @param email [String] the Parse user email.
101
115
  # @param opts [Hash] additional options to pass to the {Parse::Client} request.
102
116
  # @param headers [Hash] additional HTTP headers to send with the request.
117
+ # @raise [RuntimeError] when the per-email request rate is exceeded.
103
118
  # @return [Parse::Response]
104
119
  def request_password_reset(email, headers: {}, **opts)
120
+ rate_key = "pwreset:#{email}"
121
+ check_login_rate_limit!(rate_key)
105
122
  body = { email: email }
106
- request :post, REQUEST_PASSWORD_RESET, body: body, opts: opts, headers: headers
123
+ response = request :post, REQUEST_PASSWORD_RESET, body: body, opts: opts, headers: headers
124
+ # Always count the attempt as a "failure" for backoff purposes:
125
+ # the response body is intentionally indistinguishable across
126
+ # found/not-found emails, so we cannot reset the counter on
127
+ # "success" without leaking that distinction to an attacker who
128
+ # is probing.
129
+ track_login_attempt(rate_key, false)
130
+ response
107
131
  end
108
132
 
109
133
  # Login a user. Implements client-side rate limiting with exponential
@@ -101,6 +101,74 @@ module Parse
101
101
  indexes.find { |idx| idx["name"] == index_name }
102
102
  end
103
103
 
104
+ # List only `type: "search"` indexes (lexical / BM25). Filters the
105
+ # raw {.list_indexes} output. `$listSearchIndexes` returns both
106
+ # search and vectorSearch index types on the same collection;
107
+ # callers building lexical pipelines should consume this view.
108
+ # Indexes with an absent `type` field default to `"search"` for
109
+ # compatibility with older Atlas Search releases that pre-date the
110
+ # vector-search index type.
111
+ #
112
+ # @param collection_name [String] the Parse collection name
113
+ # @return [Array<Hash>] search indexes only
114
+ def list_search_indexes(collection_name)
115
+ list_indexes(collection_name).select do |idx|
116
+ type = (idx["type"] || idx[:type] || "search").to_s
117
+ type == "search"
118
+ end
119
+ end
120
+
121
+ # List only `type: "vectorSearch"` indexes. Filters the raw
122
+ # {.list_indexes} output. Vector-search indexes are the only ones
123
+ # eligible for the `$vectorSearch` aggregation stage; routing a
124
+ # lexical-index name into vector search (or vice versa) fails at
125
+ # the Atlas Search node with a non-obvious error, so the SDK
126
+ # filters at lookup time.
127
+ #
128
+ # @param collection_name [String] the Parse collection name
129
+ # @return [Array<Hash>] vector-search indexes only
130
+ def list_vector_indexes(collection_name)
131
+ list_indexes(collection_name).select do |idx|
132
+ (idx["type"] || idx[:type]).to_s == "vectorSearch"
133
+ end
134
+ end
135
+
136
+ # Find the vector-search index that covers a given field path on a
137
+ # collection. Walks `latestDefinition.fields[]` looking for an
138
+ # entry whose `type` is `"vector"` and whose `path` matches
139
+ # `field`. Returns the first matching index, or nil if none.
140
+ #
141
+ # The shape this matches is the documented Atlas vectorSearch
142
+ # index definition:
143
+ #
144
+ # {
145
+ # "name" => "Movie_embedding_..._idx",
146
+ # "type" => "vectorSearch",
147
+ # "latestDefinition" => {
148
+ # "fields" => [
149
+ # { "type" => "vector", "path" => "embedding",
150
+ # "numDimensions" => 1536, "similarity" => "cosine" },
151
+ # { "type" => "filter", "path" => "wiki_id" }
152
+ # ]
153
+ # }
154
+ # }
155
+ #
156
+ # @param collection_name [String]
157
+ # @param field [String, Symbol] the vector field path
158
+ # @return [Hash, nil] the index definition or nil
159
+ def find_vector_index(collection_name, field:)
160
+ field_str = field.to_s
161
+ list_vector_indexes(collection_name).find do |idx|
162
+ defn = idx["latestDefinition"] || idx[:latestDefinition] || {}
163
+ fields = defn["fields"] || defn[:fields] || []
164
+ fields.any? do |f|
165
+ type = (f["type"] || f[:type]).to_s
166
+ path = (f["path"] || f[:path]).to_s
167
+ type == "vector" && path == field_str
168
+ end
169
+ end
170
+ end
171
+
104
172
  # Validate that an index exists and is ready
105
173
  # @param collection_name [String] the Parse collection name
106
174
  # @param index_name [String] the index name to validate
@@ -349,5 +417,21 @@ module Parse
349
417
  end
350
418
  end
351
419
  end
420
+
421
+ # Alias for {IndexManager} exposing the type-aware lookup surface
422
+ # ({IndexManager.list_search_indexes},
423
+ # {IndexManager.list_vector_indexes},
424
+ # {IndexManager.find_vector_index}). Backed by the same module, so
425
+ # the cache, mutex, and TTL are shared across both names — calling
426
+ # `IndexCatalog.clear_cache` and `IndexManager.clear_cache`
427
+ # invalidates the same store.
428
+ #
429
+ # @example Discover the vector index covering Movie.embedding
430
+ # idx = Parse::AtlasSearch::IndexCatalog.find_vector_index(
431
+ # "Movie", field: "embedding"
432
+ # )
433
+ # idx&.dig("name")
434
+ # # => "Movie_embedding_openai_text_embedding_ada_002_1536_idx"
435
+ IndexCatalog = IndexManager
352
436
  end
353
437
  end