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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +856 -0
- data/README.md +15 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +190 -14
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +318 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +93 -1
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +21 -3
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +290 -17
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +72 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1810
|
-
|
|
1811
|
-
@
|
|
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
|
|
1835
|
-
MCPRackApp.adjust_listening_stream_count(-1) if
|
|
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
|
-
|
|
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
|
-
@
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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
|
|
1930
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
-
|
|
1971
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
2007
|
-
|
|
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"
|