parse-stack-next 5.7.5 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +856 -0
  3. data/README.md +15 -4
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +190 -14
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +318 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +290 -17
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
@@ -313,6 +313,17 @@ module Parse
313
313
  # explicit `streaming:` or `notifications:` also raises, since the
314
314
  # switch already owns those toggles. Requires a streaming-capable Rack
315
315
  # server (Puma, Falcon, Unicorn); has no effect under WEBrick.
316
+ # @param listening_stream_revalidator [#call, nil] re-checks the caller's
317
+ # identity on long-lived GET listening streams. Called with the agent
318
+ # that opened the stream every `listening_stream_revalidate_interval`
319
+ # seconds; a falsy return (or a raise) closes the stream, which tears
320
+ # down that session's subscriptions. POST requests re-authenticate
321
+ # through the agent factory on every call, but a listening stream is
322
+ # authenticated once at attach, so this is what bounds how long a
323
+ # revoked session keeps receiving notifications. `nil` (default) skips
324
+ # revalidation. {MCPRackApp.user_scoped} installs one.
325
+ # @param listening_stream_revalidate_interval [Numeric, nil] seconds
326
+ # between revalidations. Required (positive) when a revalidator is set.
316
327
  # @raise [ArgumentError] if both or neither of agent_factory/block are given.
317
328
  def initialize(agent_factory: nil, max_body_size: DEFAULT_MAX_BODY_SIZE,
318
329
  logger: nil, streaming: nil,
@@ -328,6 +339,8 @@ module Parse
328
339
  transport: nil,
329
340
  approval_timeout: DEFAULT_APPROVAL_TIMEOUT,
330
341
  principal_resolver: nil,
342
+ listening_stream_revalidator: nil,
343
+ listening_stream_revalidate_interval: nil,
331
344
  health_path: nil, &block)
332
345
  if agent_factory && block
333
346
  raise ArgumentError, "Provide agent_factory: OR a block, not both"
@@ -415,12 +428,36 @@ module Parse
415
428
  # Binds each MCP session id to the principal that established it so a
416
429
  # listening stream can't be hijacked by another authenticated caller.
417
430
  # Same per-instance / single-process scope as @cancellation_registry.
418
- @session_owners = SessionOwnerRegistry.new
431
+ # Live sessions (an attached listening stream or a pending approval)
432
+ # keep their owner binding under LRU pressure.
433
+ @session_owners = SessionOwnerRegistry.new(
434
+ # A session holding subscriptions is pinned too: evicting its
435
+ # binding would leave LiveQuery subscriptions (and a global session
436
+ # slot) owned by no one, outside every per-principal bound.
437
+ pinned: lambda do |sid|
438
+ @pending_elicitations.pending_for?(sid) ||
439
+ (@subscription_manager.respond_to?(:listener?) && @subscription_manager.listener?(sid)) ||
440
+ (@subscription_manager.respond_to?(:subscriptions?) && @subscription_manager.subscriptions?(sid))
441
+ end,
442
+ )
419
443
  if principal_resolver && !principal_resolver.respond_to?(:call)
420
444
  raise ArgumentError, "principal_resolver must respond to #call"
421
445
  end
422
446
  @principal_resolver = principal_resolver
423
447
 
448
+ if listening_stream_revalidator
449
+ unless listening_stream_revalidator.respond_to?(:call)
450
+ raise ArgumentError, "listening_stream_revalidator must respond to #call"
451
+ end
452
+ unless listening_stream_revalidate_interval.is_a?(Numeric) && listening_stream_revalidate_interval.positive?
453
+ raise ArgumentError,
454
+ "listening_stream_revalidate_interval must be a positive Numeric when a " \
455
+ "listening_stream_revalidator is set (got #{listening_stream_revalidate_interval.inspect})"
456
+ end
457
+ end
458
+ @listening_stream_revalidator = listening_stream_revalidator
459
+ @listening_stream_revalidate_interval = listening_stream_revalidate_interval
460
+
424
461
  # Listening-stream coordinator (the server→client broadcast bus
425
462
  # backing resource subscriptions, MCP elicitation, and
426
463
  # general-purpose server-initiated notifications). An injected
@@ -625,6 +662,27 @@ module Parse
625
662
  if clean_sid.nil?
626
663
  return [400, json_headers, [json_rpc_error(-32_600, "Invalid Mcp-Session-Id")]]
627
664
  end
665
+ # Termination is gated like every other session operation: the
666
+ # Origin policy, authentication through the agent factory, and the
667
+ # session's owner binding. Before 5.8 any caller who knew a session
668
+ # id could terminate it (cancel its requests, drop its approvals,
669
+ # subscriptions, and log level) without authenticating.
670
+ if origin_refused?(env)
671
+ return [403, json_headers, [json_rpc_error(-32_700, "Origin not allowed")]]
672
+ end
673
+ begin
674
+ terminating_agent = @agent_factory.call(env)
675
+ rescue Parse::Agent::Unauthorized
676
+ @logger&.warn("[Parse::Agent::MCPRackApp] Unauthorized session termination")
677
+ return [401, json_headers, [unauthorized_body]]
678
+ rescue StandardError => e
679
+ @logger&.warn("[Parse::Agent::MCPRackApp] Factory error on DELETE: #{e.class.name}")
680
+ return [500, json_headers, [json_rpc_error(-32_603, "Internal error")]]
681
+ end
682
+ if @session_owners.claimed_by_other?(clean_sid, principal_fingerprint(terminating_agent, env))
683
+ @logger&.warn("[Parse::Agent::MCPRackApp] session termination refused: owned by another principal")
684
+ return [403, json_headers, [json_rpc_error(-32_600, "Mcp-Session-Id is owned by another principal")]]
685
+ end
628
686
  @cancellation_registry.cancel_all_for(clean_sid, reason: :session_terminated)
629
687
  # Wake any tool thread blocked on an elicitation reply for this
630
688
  # session (it returns `unavailable` → fail closed) and drop the
@@ -824,7 +882,15 @@ module Parse
824
882
  # level, or have its elicitation capability recorded (owner-binding;
825
883
  # see SessionOwnerRegistry). A session id already owned by another
826
884
  # principal is refused outright rather than rebound.
827
- unless @session_owners.bind(agent.correlation_id, principal_fingerprint(agent, env))
885
+ if (limited = charge_session_op(agent, body))
886
+ return limited
887
+ end
888
+ bound = @session_owners.bind(agent.correlation_id, principal_fingerprint(agent, env))
889
+ if bound == :full
890
+ @logger&.warn("[Parse::Agent::MCPRackApp] initialize refused: session registry full")
891
+ return [503, json_headers, [json_rpc_error(-32_000, "Session capacity exhausted", id: body["id"])]]
892
+ end
893
+ unless bound
828
894
  @logger&.warn("[Parse::Agent::MCPRackApp] initialize refused: session owned by another principal")
829
895
  return [403, json_headers,
830
896
  [json_rpc_error(-32_600, "Mcp-Session-Id is owned by another principal", id: body["id"])]]
@@ -841,7 +907,9 @@ module Parse
841
907
  # Failures (no correlation_id, no match) are silent 202 no-ops
842
908
  # to avoid a probe oracle — exactly like notifications/cancelled.
843
909
  if elicitation_reply?(body)
844
- route_elicitation_reply(agent, body)
910
+ # Only the session's owner may answer its approval prompts. A
911
+ # mismatch is the same silent 202 as any other miss (no oracle).
912
+ route_elicitation_reply(agent, body) if session_controllable?(agent, env)
845
913
  return [202, json_headers, [""]]
846
914
  end
847
915
 
@@ -859,7 +927,11 @@ module Parse
859
927
  # always 202 Accepted with an empty body.
860
928
  if body.is_a?(Hash) && body["method"] == "notifications/cancelled"
861
929
  request_id = body.dig("params", "requestId")
862
- if agent.respond_to?(:correlation_id) && agent.correlation_id && request_id
930
+ # Only the session's owner may cancel its in-flight requests; a
931
+ # caller who merely knows (or chose) the same Mcp-Session-Id gets the
932
+ # same silent 202 as any other miss.
933
+ if agent.respond_to?(:correlation_id) && agent.correlation_id && request_id &&
934
+ session_controllable?(agent, env)
863
935
  @cancellation_registry.cancel(
864
936
  agent.correlation_id,
865
937
  request_id,
@@ -869,6 +941,41 @@ module Parse
869
941
  return [202, json_headers, [""]]
870
942
  end
871
943
 
944
+ # 5d. Session ownership for every other request. A request carrying a
945
+ # session id bound to another principal is refused, so knowing a
946
+ # session id is not enough to unsubscribe its resources, fill its
947
+ # subscription cap, or route an approval prompt to its stream.
948
+ # An unbound id (stateless clients, or a cluster where initialize
949
+ # landed on another worker) still works for ordinary calls.
950
+ if (cid = agent.respond_to?(:correlation_id) ? agent.correlation_id : nil) &&
951
+ body.is_a?(Hash) && body["method"] != "initialize"
952
+ fingerprint = principal_fingerprint(agent, env)
953
+ if @session_owners.claimed_by_other?(cid, fingerprint)
954
+ @logger&.warn("[Parse::Agent::MCPRackApp] request refused: session owned by another principal")
955
+ return [403, json_headers,
956
+ [json_rpc_error(-32_600, "Mcp-Session-Id is owned by another principal", id: body["id"])]]
957
+ end
958
+ # Subscriptions hold server resources (LiveQuery sockets, global
959
+ # session slots), so they need a session this principal established
960
+ # (initialize, or a listening stream it attached). Without that, a
961
+ # caller could invent session ids to fill the global session limit.
962
+ if SESSION_BOUND_METHODS.include?(body["method"]) && @subscription_manager
963
+ unless @session_owners.owned_by?(cid, fingerprint)
964
+ # Not owned by anyone else (that was refused above), so the id
965
+ # is unknown here: never initialized, or bound on another
966
+ # process before a restart. 404 is the MCP signal for an
967
+ # unknown session; a compliant client re-initializes.
968
+ return [404, json_headers,
969
+ [json_rpc_error(-32_001,
970
+ "Unknown session; send initialize to start a new one",
971
+ id: body["id"])]]
972
+ end
973
+ if (limited = charge_session_op(agent, body))
974
+ return limited
975
+ end
976
+ end
977
+ end
978
+
872
979
  # 6. Branch on streaming preference. Transport-level errors (steps 1-5)
873
980
  # always return plain JSON regardless of the Accept header.
874
981
  log_levels = session_log_levels(agent, env)
@@ -959,6 +1066,37 @@ module Parse
959
1066
  )
960
1067
  end
961
1068
 
1069
+ # Methods that act on server-held session state and so require a session
1070
+ # bound to the caller (see step 5d in #call).
1071
+ SESSION_BOUND_METHODS = %w[resources/subscribe].freeze
1072
+
1073
+ # Charge a session-creating operation (initialize, subscribe) against
1074
+ # the agent's rate limiter, which these never reach through
1075
+ # `agent.execute`. With a per-principal limiter (`user_scoped`,
1076
+ # `master_analytics`) this bounds how fast one caller can create
1077
+ # sessions or subscriptions.
1078
+ #
1079
+ # @return [Array, nil] a 429 Rack response when limited, else nil.
1080
+ def charge_session_op(agent, body)
1081
+ limiter = agent.respond_to?(:rate_limiter) ? agent.rate_limiter : nil
1082
+ return nil unless limiter.respond_to?(:check!)
1083
+ limiter.check!
1084
+ nil
1085
+ rescue StandardError => e
1086
+ retry_after = e.respond_to?(:retry_after) ? e.retry_after.to_f.ceil : 1
1087
+ headers = json_headers.merge("retry-after" => [retry_after, 1].max.to_s)
1088
+ [429, headers, [json_rpc_error(-32_000, "Rate limit exceeded", id: body["id"])]]
1089
+ end
1090
+
1091
+ # Whether this request's principal may send control messages
1092
+ # (cancellation, elicitation replies) for its Mcp-Session-Id: true for an
1093
+ # unbound session or one bound to this principal.
1094
+ def session_controllable?(agent, env)
1095
+ cid = agent.respond_to?(:correlation_id) ? agent.correlation_id : nil
1096
+ return false if cid.nil? || cid.to_s.empty?
1097
+ @session_owners.controllable_by?(cid, principal_fingerprint(agent, env))
1098
+ end
1099
+
962
1100
  # The log-level registry this request may read and write, or nil.
963
1101
  #
964
1102
  # nil when the app does not stream (log messages could never be
@@ -1125,25 +1263,40 @@ module Parse
1125
1263
  # infeasible to enumerate. (Contrast the cancellation/elicitation
1126
1264
  # paths, which return a uniform 202 because their ids are
1127
1265
  # client-chosen and guessable.)
1128
- unless @session_owners.authorize_attach(session_id, principal_fingerprint(agent, env))
1129
- @logger&.warn("[Parse::Agent::MCPRackApp] Listening stream denied: session owned by another principal")
1130
- return [403, json_headers, [json_rpc_error(-32_600, "Mcp-Session-Id is owned by another principal")]]
1131
- end
1132
-
1133
- # Soft cap on concurrent listening streams, mirroring serve_sse's
1134
- # dispatcher cap. Listening streams are bounded SEPARATELY from
1135
- # request-scoped SSE dispatchers and reuse the same configured ceiling,
1136
- # so total streaming thread exposure can reach 2x max_concurrent_dispatchers
1137
- # (up to N request SSE + N listening streams), not N. Like serve_sse the
1138
- # check is best-effort (not lock-protected against the per-stream
1139
- # increment in #each), so a burst can briefly overshoot — acceptable for
1140
- # a soft cap.
1266
+ # Check capacity and charge the principal BEFORE claiming the id, so a
1267
+ # refused stream leaves no binding behind and a caller cannot claim
1268
+ # ids by opening and dropping streams faster than its rate limit.
1141
1269
  if @max_concurrent_dispatchers &&
1142
1270
  MCPRackApp.active_listening_stream_count >= @max_concurrent_dispatchers
1143
1271
  return [503, json_headers, [json_rpc_error(-32_000, "server busy")]]
1144
1272
  end
1273
+ if (limited = charge_session_op(agent, {}))
1274
+ return limited
1275
+ end
1276
+
1277
+ attach = @session_owners.authorize_attach(session_id, principal_fingerprint(agent, env))
1278
+ if attach == :full
1279
+ @logger&.warn("[Parse::Agent::MCPRackApp] Listening stream denied: session registry full")
1280
+ return [503, json_headers, [json_rpc_error(-32_000, "Session capacity exhausted")]]
1281
+ end
1282
+ unless attach
1283
+ @logger&.warn("[Parse::Agent::MCPRackApp] Listening stream denied: session owned by another principal")
1284
+ return [403, json_headers, [json_rpc_error(-32_600, "Mcp-Session-Id is owned by another principal")]]
1285
+ end
1286
+
1287
+ # (The soft cap on concurrent listening streams is checked above,
1288
+ # before the claim. Listening streams are bounded separately from
1289
+ # request-scoped SSE dispatchers and reuse the same ceiling, so total
1290
+ # streaming thread exposure can reach 2x max_concurrent_dispatchers.
1291
+ # The check is best-effort, so a burst can briefly overshoot.)
1145
1292
 
1146
- body = ListeningStreamBody.new(@subscription_manager, session_id, @heartbeat_interval, @logger)
1293
+ revalidate = if @listening_stream_revalidator
1294
+ revalidator = @listening_stream_revalidator
1295
+ -> { revalidator.call(agent) }
1296
+ end
1297
+ body = ListeningStreamBody.new(@subscription_manager, session_id, @heartbeat_interval, @logger,
1298
+ revalidate: revalidate,
1299
+ revalidate_interval: @listening_stream_revalidate_interval)
1147
1300
  [200, sse_headers, body]
1148
1301
  end
1149
1302
 
@@ -1785,16 +1938,25 @@ module Parse
1785
1938
  # @param heartbeat_interval [Numeric] SSE comment heartbeat period in
1786
1939
  # seconds; `<= 0` disables heartbeats.
1787
1940
  # @param logger [#warn, nil]
1788
- def initialize(manager, session_id, heartbeat_interval, logger)
1941
+ # @param revalidate [#call, nil] identity re-check run every
1942
+ # `revalidate_interval` seconds; a falsy return or a raise closes the
1943
+ # stream. See MCPRackApp's `listening_stream_revalidator:`.
1944
+ # @param revalidate_interval [Numeric, nil]
1945
+ def initialize(manager, session_id, heartbeat_interval, logger,
1946
+ revalidate: nil, revalidate_interval: nil)
1789
1947
  @manager = manager
1790
1948
  @session_id = session_id
1791
1949
  @heartbeat_interval = heartbeat_interval
1792
1950
  @logger = logger
1951
+ @revalidate = revalidate
1952
+ @revalidate_interval = revalidate_interval
1953
+ @revalidator_thread = nil
1793
1954
  @queue = Queue.new
1794
1955
  @heartbeat = nil
1795
1956
  @closed = false
1796
1957
  @counted = false
1797
1958
  @close_mutex = Mutex.new
1959
+ @close_signal = ConditionVariable.new
1798
1960
  end
1799
1961
 
1800
1962
  # Rack body interface — called once by the Rack server.
@@ -1806,14 +1968,29 @@ module Parse
1806
1968
  # Rack server never iterates — or a client that disconnects before
1807
1969
  # iteration — never inflates the counter; the matching decrement is in
1808
1970
  # #close, which #each's `ensure` always runs.
1809
- MCPRackApp.adjust_listening_stream_count(1)
1810
- @counted = true
1811
- @manager.attach_listener(@session_id) do |notification|
1971
+ # Increment and record it under the close lock, so a close racing
1972
+ # this cannot skip the matching decrement and leak a stream slot.
1973
+ @close_mutex.synchronize do
1974
+ return if @closed
1975
+ MCPRackApp.adjust_listening_stream_count(1)
1976
+ @counted = true
1977
+ end
1978
+ listener = lambda do |notification|
1812
1979
  queue << format_event(notification)
1813
1980
  end
1981
+ # Attach under the close lock so a close that races this attach
1982
+ # either runs first (nothing is attached) or sees @listener set
1983
+ # and detaches it. Without the lock a close landing just before
1984
+ # the attach would leave the listener registered with no stream.
1985
+ @close_mutex.synchronize do
1986
+ return if @closed
1987
+ @manager.attach_listener(@session_id, &listener)
1988
+ @listener = listener
1989
+ end
1814
1990
  # Initial comment flushes response headers and confirms the stream.
1815
1991
  yield ": connected\n\n"
1816
1992
  start_heartbeat
1993
+ start_revalidation
1817
1994
  loop do
1818
1995
  msg = @queue.pop
1819
1996
  break if msg == DONE
@@ -1826,17 +2003,38 @@ module Parse
1826
2003
  # Terminate the stream: stop heartbeats, detach the listener, and tear
1827
2004
  # down the session's LiveQuery subscriptions. Idempotent.
1828
2005
  def close
1829
- @close_mutex.synchronize do
2006
+ counted = @close_mutex.synchronize do
1830
2007
  return if @closed
1831
2008
  @closed = true
2009
+ # Wake the revalidator so it exits at once instead of after its
2010
+ # next interval.
2011
+ @close_signal.broadcast
2012
+ @counted
1832
2013
  end
1833
2014
  # Balance the #each increment exactly once (close is idempotent via
1834
- # @closed, and only #each sets @counted).
1835
- MCPRackApp.adjust_listening_stream_count(-1) if @counted
2015
+ # @closed, and #each counts only under the same lock).
2016
+ MCPRackApp.adjust_listening_stream_count(-1) if counted
1836
2017
  @heartbeat&.kill
1837
2018
  @heartbeat = nil
2019
+ # The revalidator is not killed: it may be inside a REST call, and
2020
+ # killing it there can return a pooled connection mid-response. It
2021
+ # was woken above and exits after any check in progress.
2022
+ @revalidator_thread = nil
1838
2023
  begin
1839
- @manager.detach_listener(@session_id)
2024
+ # Only a stream that attached detaches: a body closed before
2025
+ # Rack iterated it (or before its attach) never registered a
2026
+ # listener, and detaching would tear down the session's active
2027
+ # stream. Pass this stream's own callback so a reconnect that
2028
+ # already attached a newer stream is not torn down either.
2029
+ # Custom managers with a one-argument detach_listener still work.
2030
+ listener = @close_mutex.synchronize { @listener }
2031
+ if listener.nil?
2032
+ nil
2033
+ elsif @manager.method(:detach_listener).arity != 1
2034
+ @manager.detach_listener(@session_id, listener)
2035
+ else
2036
+ @manager.detach_listener(@session_id)
2037
+ end
1840
2038
  rescue StandardError => e
1841
2039
  line = "[Parse::Agent::MCPRackApp::ListeningStreamBody] detach error: #{e.class}: #{e.message}"
1842
2040
  @logger ? @logger.warn(line) : warn(line)
@@ -1846,15 +2044,77 @@ module Parse
1846
2044
 
1847
2045
  private
1848
2046
 
2047
+ # @return [Boolean] true once {#close} has run.
2048
+ def closed?
2049
+ @close_mutex.synchronize { @closed }
2050
+ end
2051
+
2052
+ # Background threads start under the same mutex {#close} takes, and
2053
+ # never once the stream is closed. A stream can close while #each is
2054
+ # still yielding its first frame (client disconnect, DELETE); without
2055
+ # this, the threads would start after close had already run and keep
2056
+ # running (the revalidator re-authenticating indefinitely).
1849
2057
  def start_heartbeat
1850
2058
  return unless @heartbeat_interval && @heartbeat_interval > 0
1851
2059
  queue = @queue
1852
2060
  interval = @heartbeat_interval
1853
- @heartbeat = Thread.new do
1854
- loop do
1855
- sleep interval
1856
- queue << ": keep-alive\n\n"
2061
+ @close_mutex.synchronize do
2062
+ return if @closed
2063
+ @heartbeat = Thread.new do
2064
+ loop do
2065
+ sleep interval
2066
+ break if closed?
2067
+ queue << ": keep-alive\n\n"
2068
+ end
2069
+ end
2070
+ end
2071
+ end
2072
+
2073
+ # Re-check the caller's identity on a timer; close the stream the first
2074
+ # time the check fails. close runs the normal teardown (detach the
2075
+ # listener, unsubscribe the session's LiveQuery subscriptions) and
2076
+ # wakes #each, which then ends the response.
2077
+ def start_revalidation
2078
+ return unless @revalidate && @revalidate_interval && @revalidate_interval > 0
2079
+ check = @revalidate
2080
+ interval = @revalidate_interval
2081
+ @close_mutex.synchronize do
2082
+ return if @closed
2083
+ @revalidator_thread = Thread.new { revalidation_loop(check, interval) }
2084
+ end
2085
+ end
2086
+
2087
+ # Consecutive revalidation errors (as opposed to a check that reports
2088
+ # the identity invalid) tolerated before the stream is closed. One
2089
+ # Parse Server blip should not drop every open stream at once; an
2090
+ # outage that persists still closes them, since the identity can no
2091
+ # longer be confirmed.
2092
+ MAX_REVALIDATION_ERRORS = 3
2093
+
2094
+ def revalidation_loop(check, interval)
2095
+ errors = 0
2096
+ loop do
2097
+ @close_mutex.synchronize do
2098
+ @close_signal.wait(@close_mutex, interval) unless @closed
1857
2099
  end
2100
+ break if closed?
2101
+ ok = begin
2102
+ result = check.call
2103
+ errors = 0
2104
+ result
2105
+ rescue StandardError => e
2106
+ errors += 1
2107
+ line = "[Parse::Agent::MCPRackApp::ListeningStreamBody] revalidation error: #{e.class}"
2108
+ @logger ? @logger.warn(line) : warn(line)
2109
+ errors < MAX_REVALIDATION_ERRORS ? :retry : false
2110
+ end
2111
+ next if ok
2112
+ break if closed?
2113
+ line = "[Parse::Agent::MCPRackApp::ListeningStreamBody] closing listening stream: " \
2114
+ "caller identity no longer valid"
2115
+ @logger ? @logger.warn(line) : warn(line)
2116
+ close
2117
+ break
1858
2118
  end
1859
2119
  end
1860
2120
 
@@ -1926,14 +2186,31 @@ module Parse
1926
2186
  # per-user impersonation) supplies a real identity.
1927
2187
  #
1928
2188
  # LRU-bounded so an initialize-without-DELETE stream of sessions can't
1929
- # grow it without limit; evicting an active owner just downgrades it to
1930
- # TOFU on the next attach.
2189
+ # grow it without limit. Live sessions (an attached listening stream or a
2190
+ # pending approval) are pinned and never evicted; an evicted idle id
2191
+ # downgrades to TOFU on its next attach. Each principal may hold at most
2192
+ # `max_per_principal` bindings: past that, its own least recently used
2193
+ # idle binding is evicted, so one caller flooding `initialize` cannot
2194
+ # push other principals' idle sessions out of the registry.
1931
2195
  class SessionOwnerRegistry
1932
2196
  DEFAULT_MAX_ENTRIES = 10_000
1933
-
1934
- def initialize(max_entries: DEFAULT_MAX_ENTRIES)
2197
+ DEFAULT_MAX_PER_PRINCIPAL = 100
2198
+ # Fingerprints shared by every caller of an endpoint (a master-key
2199
+ # factory with no `principal_resolver`). A per-principal bound on them
2200
+ # would be a bound on the whole endpoint, so only the global one applies.
2201
+ SHARED_PRINCIPALS = %w[mk].freeze
2202
+
2203
+ # @param pinned [#call, nil] `->(session_id) { Boolean }`; a pinned
2204
+ # session's binding is never evicted (see #evict_lru!).
2205
+ # @param max_per_principal [Integer, nil] bindings one principal may
2206
+ # hold; nil disables the per-principal bound.
2207
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES, pinned: nil,
2208
+ max_per_principal: DEFAULT_MAX_PER_PRINCIPAL)
1935
2209
  @owners = {} # session_id => principal fingerprint (insertion-ordered for LRU)
2210
+ @counts = Hash.new(0) # principal fingerprint => bindings held
1936
2211
  @max = max_entries
2212
+ @max_per_principal = max_per_principal
2213
+ @pinned = pinned
1937
2214
  @mutex = Mutex.new
1938
2215
  end
1939
2216
 
@@ -1951,10 +2228,14 @@ module Parse
1951
2228
  @mutex.synchronize do
1952
2229
  owner = @owners[session_id]
1953
2230
  return false if owner && owner != fingerprint
1954
- @owners.delete(session_id)
1955
- @owners[session_id] = fingerprint
1956
- evict_lru!
1957
- true
2231
+ if owner
2232
+ @owners.delete(session_id)
2233
+ @owners[session_id] = fingerprint
2234
+ true
2235
+ else
2236
+ store(session_id, fingerprint)
2237
+ retain_or_reject!(session_id, fingerprint)
2238
+ end
1958
2239
  end
1959
2240
  end
1960
2241
 
@@ -1967,9 +2248,8 @@ module Parse
1967
2248
  @mutex.synchronize do
1968
2249
  owner = @owners[session_id]
1969
2250
  if owner.nil?
1970
- @owners[session_id] = fingerprint
1971
- evict_lru!
1972
- true
2251
+ store(session_id, fingerprint)
2252
+ retain_or_reject!(session_id, fingerprint)
1973
2253
  elsif owner == fingerprint
1974
2254
  @owners.delete(session_id)
1975
2255
  @owners[session_id] = owner
@@ -1980,11 +2260,38 @@ module Parse
1980
2260
  end
1981
2261
  end
1982
2262
 
2263
+ # True when the session is bound to a principal other than this one.
2264
+ # An unclaimed session is not claimed by anyone else.
2265
+ def claimed_by_other?(session_id, fingerprint)
2266
+ return false if blank?(session_id)
2267
+ @mutex.synchronize do
2268
+ owner = @owners[session_id]
2269
+ touch(session_id, owner) if owner && owner == fingerprint
2270
+ !owner.nil? && owner != fingerprint
2271
+ end
2272
+ end
2273
+
2274
+ # True when the session is unbound (never initialized or attached) or
2275
+ # bound to this principal. Used to gate per-session control messages
2276
+ # (cancellation, elicitation replies): an unbound session has no owner
2277
+ # to protect, while a bound one only accepts its owner.
2278
+ def controllable_by?(session_id, fingerprint)
2279
+ return false if blank?(session_id) || blank?(fingerprint)
2280
+ @mutex.synchronize do
2281
+ owner = @owners[session_id]
2282
+ owner.nil? || owner == fingerprint
2283
+ end
2284
+ end
2285
+
1983
2286
  # True when `session_id` is bound to exactly this principal. Never
1984
2287
  # claims an unbound session (unlike {#authorize_attach}).
1985
2288
  def owned_by?(session_id, fingerprint)
1986
2289
  return false if blank?(session_id) || blank?(fingerprint)
1987
- @mutex.synchronize { @owners[session_id] == fingerprint }
2290
+ @mutex.synchronize do
2291
+ owned = @owners[session_id] == fingerprint
2292
+ touch(session_id, fingerprint) if owned
2293
+ owned
2294
+ end
1988
2295
  end
1989
2296
 
1990
2297
  # Drop a session's owner binding (explicit DELETE termination). Not
@@ -1992,7 +2299,7 @@ module Parse
1992
2299
  # and an attacker can't grab the id during a brief disconnect.
1993
2300
  def forget(session_id)
1994
2301
  return if blank?(session_id)
1995
- @mutex.synchronize { @owners.delete(session_id) }
2302
+ @mutex.synchronize { remove(session_id) }
1996
2303
  end
1997
2304
 
1998
2305
  # @return [Integer] current number of bound sessions (tests/metrics).
@@ -2003,8 +2310,77 @@ module Parse
2003
2310
  private
2004
2311
 
2005
2312
  # Hash preserves insertion order; #shift drops the oldest (LRU) entry.
2006
- def evict_lru!
2007
- @owners.shift while @owners.size > @max
2313
+ # Evict least recently used bindings past the cap, skipping sessions
2314
+ # the `pinned` predicate reports as live (an attached listening stream
2315
+ # or a pending approval prompt). Evicting a live session's binding
2316
+ # would make it unbound, and an unbound session accepts control
2317
+ # messages from anyone, so a flood of new sessions could otherwise
2318
+ # strip a victim's owner and let the flooder answer its approvals.
2319
+ #
2320
+ # A pinned entry the scan passes over is moved to the tail, so the
2321
+ # next eviction does not re-check the same live sessions from the head
2322
+ # (which would make every bind at capacity O(live sessions) under the
2323
+ # lock). With `principal:`, only that principal's bindings are
2324
+ # candidates and the scan stops at its per-principal bound.
2325
+ def evict_lru!(protect: nil, principal: nil)
2326
+ over = lambda do
2327
+ principal ? @counts[principal] > @max_per_principal : @owners.size > @max
2328
+ end
2329
+ return unless over.call
2330
+ @owners.keys.each do |sid|
2331
+ break unless over.call
2332
+ next if sid == protect
2333
+ owner = @owners[sid]
2334
+ next if principal && owner != principal
2335
+ if @pinned && (@pinned.call(sid) rescue false)
2336
+ @owners.delete(sid)
2337
+ @owners[sid] = owner
2338
+ next
2339
+ end
2340
+ remove(sid)
2341
+ end
2342
+ end
2343
+
2344
+ # Make room for a binding just written for `session_id`, never by
2345
+ # evicting that binding itself. The principal's own idle bindings go
2346
+ # first (per-principal bound), then the global LRU. When the room
2347
+ # cannot be made because every candidate is pinned, the new binding is
2348
+ # removed and :full returned so the caller refuses admission, rather
2349
+ # than reporting success for a session that has no owner (which would
2350
+ # let any caller attach to or control it).
2351
+ #
2352
+ # @return [true, :full]
2353
+ def retain_or_reject!(session_id, fingerprint)
2354
+ if @max_per_principal && !SHARED_PRINCIPALS.include?(fingerprint)
2355
+ evict_lru!(protect: session_id, principal: fingerprint)
2356
+ if @counts[fingerprint] > @max_per_principal
2357
+ remove(session_id)
2358
+ return :full
2359
+ end
2360
+ end
2361
+ evict_lru!(protect: session_id)
2362
+ return true if @owners.size <= @max
2363
+ remove(session_id)
2364
+ :full
2365
+ end
2366
+
2367
+ # Refresh a binding's LRU position: an owner's ordinary requests keep
2368
+ # its session from being evicted as idle.
2369
+ def touch(session_id, owner)
2370
+ @owners.delete(session_id)
2371
+ @owners[session_id] = owner
2372
+ end
2373
+
2374
+ def store(session_id, fingerprint)
2375
+ @owners[session_id] = fingerprint
2376
+ @counts[fingerprint] += 1
2377
+ end
2378
+
2379
+ def remove(session_id)
2380
+ owner = @owners.delete(session_id)
2381
+ return unless owner
2382
+ @counts[owner] -= 1
2383
+ @counts.delete(owner) if @counts[owner] <= 0
2008
2384
  end
2009
2385
 
2010
2386
  def blank?(value)
@@ -2295,3 +2671,6 @@ module Parse
2295
2671
  end
2296
2672
  end
2297
2673
  end
2674
+
2675
+ # Supported deployment patterns (MCPRackApp.user_scoped / .master_analytics).
2676
+ require_relative "mcp_deployments"