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
@@ -0,0 +1,426 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # Supported MCP deployment patterns, packaged as {MCPRackApp} factories.
7
+ #
8
+ # Two explicit access modes, so a deployment can never drift from one into
9
+ # the other by accident:
10
+ #
11
+ # * {MCPRackApp.user_scoped}: every request carries a Parse session token.
12
+ # The agent acts as that user (ACL/CLP enforced by Parse Server on REST
13
+ # and by the SDK on mongo-direct paths). A missing, blank, invalid,
14
+ # expired, or revoked token is refused with 401 before any agent is
15
+ # built, and there is no fallback to master-key access.
16
+ #
17
+ # * {MCPRackApp.master_analytics}: master authority for analytics and
18
+ # trusted operational tools, narrowed by the agent's configured tools,
19
+ # classes, and fields, and read-only by default. Operator identity is
20
+ # mandatory: without a `principal_resolver` every master-key agent
21
+ # fingerprints as the same principal, and callers would share ownership
22
+ # of each other's streams, approvals, and cancellations.
23
+ #
24
+ # Direct `MCPRackApp.new(agent_factory: ...)` construction is unchanged
25
+ # and remains the way to build anything else, including an intentional
26
+ # single-operator master-key endpoint.
27
+ class MCPRackApp
28
+ # Default seconds between identity re-checks on a user-scoped listening
29
+ # stream. Bounds how long a revoked session keeps receiving
30
+ # notifications on an already-open stream.
31
+ DEFAULT_SESSION_REVALIDATE_INTERVAL = 60
32
+
33
+ # Rack env key where `user_scoped` records the verified user id for the
34
+ # request, read back by its principal resolver.
35
+ USER_PRINCIPAL_ENV_KEY = "parse.agent.user_scoped.user_id"
36
+
37
+ # Raised by {validate_session!} when Parse Server could not be asked
38
+ # (unreachable, 5xx, an unexpected error). Distinct from
39
+ # {Parse::Agent::Unauthorized}, which means the session was rejected.
40
+ class SessionCheckUnavailable < StandardError; end
41
+
42
+ # Accepted values for `session_validation:`.
43
+ SESSION_VALIDATION_MODES = %i[per_request cached].freeze
44
+
45
+ # One rate limiter per principal, shared by every agent a deployment
46
+ # factory builds for that principal. A factory builds a fresh agent per
47
+ # request, and each agent would otherwise get a fresh limiter, so the
48
+ # configured rate limit never accumulated across requests. Bounded
49
+ # (least recently used) so a stream of principals cannot grow it
50
+ # without limit; an evicted principal starts a new window.
51
+ class PrincipalRateLimiters
52
+ DEFAULT_MAX_ENTRIES = 10_000
53
+
54
+ def initialize(limit:, window:, max_entries: DEFAULT_MAX_ENTRIES)
55
+ @limit = limit
56
+ @window = window
57
+ @max = max_entries
58
+ @limiters = {}
59
+ @mutex = Mutex.new
60
+ end
61
+
62
+ # @param principal [String]
63
+ # @return [Parse::Agent::RateLimiter]
64
+ def fetch(principal)
65
+ key = principal.to_s
66
+ @mutex.synchronize do
67
+ limiter = @limiters.delete(key) || Parse::Agent::RateLimiter.new(limit: @limit, window: @window)
68
+ @limiters[key] = limiter
69
+ @limiters.shift while @limiters.size > @max
70
+ limiter
71
+ end
72
+ end
73
+
74
+ def size
75
+ @mutex.synchronize { @limiters.size }
76
+ end
77
+ end
78
+
79
+ # @api private
80
+ # The registry a factory uses, or nil when the caller injected its own
81
+ # `rate_limiter:` (honored as-is, e.g. a shared Redis limiter).
82
+ def self.principal_rate_limiters_for(agent_options)
83
+ # Only a real injected limiter bypasses the registry; an explicit
84
+ # `rate_limiter: nil` would otherwise give every request a fresh one.
85
+ return nil unless agent_options[:rate_limiter].nil?
86
+ PrincipalRateLimiters.new(
87
+ limit: agent_options.fetch(:rate_limit, Parse::Agent::DEFAULT_RATE_LIMIT),
88
+ window: agent_options.fetch(:rate_window, Parse::Agent::DEFAULT_RATE_WINDOW),
89
+ )
90
+ end
91
+
92
+ # Options that would give a user-scoped agent authority beyond its
93
+ # session, refused by {MCPRackApp.user_scoped}.
94
+ USER_SCOPED_REFUSED_AGENT_OPTIONS = %i[master_atlas allow_mutations].freeze
95
+
96
+ # Agent constructor options a factory owns. Passing them through
97
+ # `agent_options:` would let configuration override the identity or
98
+ # authority the factory pins, so they are refused at construction.
99
+ FACTORY_OWNED_AGENT_OPTIONS = %i[
100
+ session_token acl_user acl_role
101
+ impersonate_user impersonation_user impersonate_mint impersonation_mint
102
+ impersonate_label impersonation_label
103
+ tenant_id client permissions permission parent
104
+ ].freeze
105
+
106
+ # Rack app options a factory owns.
107
+ FACTORY_OWNED_APP_OPTIONS = %i[
108
+ agent_factory principal_resolver
109
+ listening_stream_revalidator listening_stream_revalidate_interval
110
+ ].freeze
111
+
112
+ # Default session-token extraction: `Authorization: Bearer <token>`,
113
+ # else `X-Parse-Session-Token`.
114
+ DEFAULT_SESSION_TOKEN_FROM = lambda do |env|
115
+ auth = env["HTTP_AUTHORIZATION"].to_s.strip
116
+ if (m = auth.match(/\ABearer\s+(\S+)\z/i))
117
+ m[1]
118
+ else
119
+ env["HTTP_X_PARSE_SESSION_TOKEN"]
120
+ end
121
+ end
122
+
123
+ class << self
124
+ # Build an MCP endpoint whose agents act as the calling Parse user.
125
+ #
126
+ # Every request (POST and the GET listening stream) must carry a
127
+ # session token. The token is validated before an agent is built;
128
+ # anything missing, blank, invalid, expired, or revoked gets 401.
129
+ # The resulting agent is `Parse::Agent.new(session_token: token, ...)`,
130
+ # so identity comes from the session and cannot be changed by tool
131
+ # arguments. There is no master-key fallback in this mode.
132
+ #
133
+ # **Revocation.** With the default `session_validation: :per_request`,
134
+ # each request re-checks the token against Parse Server
135
+ # (`GET /users/me`, uncached), so a logged-out, expired, or revoked
136
+ # session is refused on the next request. An already-open listening
137
+ # stream is re-checked every `session_revalidate_interval` seconds and
138
+ # closed (tearing down its subscriptions) when the check fails.
139
+ # `session_validation: :cached` trades that for fewer round trips: the
140
+ # token is resolved through `client.authorization`, so revocation takes
141
+ # effect after the identity-cache TTL unless the
142
+ # `Parse::Cache::Invalidation` hooks evict it first. See the "Deployment
143
+ # patterns" section of the MCP guide for the full interval table.
144
+ #
145
+ # @param session_token_from [#call, nil] `->(env) { token }`. Defaults
146
+ # to {DEFAULT_SESSION_TOKEN_FROM}.
147
+ # @param permissions [Symbol] agent permission tier; `:readonly`
148
+ # (default), `:write`, or `:admin`. Writes still flow only through
149
+ # declared `agent_method`s and the existing env gates.
150
+ # @param client [Parse::Client, nil] the client to validate tokens and
151
+ # build agents against. nil uses `Parse.client` at request time.
152
+ # @param tenant_from [#call, nil] `->(env, user_id) { tenant }` to pin
153
+ # the agent's `tenant_id` server-side. A nil or blank result is
154
+ # refused with 401 (fail closed).
155
+ # @param session_validation [Symbol] `:per_request` (default) or
156
+ # `:cached`. See above.
157
+ # @param session_revalidate_interval [Numeric] seconds between identity
158
+ # re-checks on an open listening stream.
159
+ # @param agent_options [Hash] extra `Parse::Agent.new` options (for
160
+ # example `tools:`, `methods:`, `classes:`, `filters:`). Identity and
161
+ # authority options in {FACTORY_OWNED_AGENT_OPTIONS} are refused.
162
+ # @param rack_options [Hash] remaining `MCPRackApp.new` options
163
+ # (`transport:`, `logger:`, `allowed_origins:`, ...). Options in
164
+ # {FACTORY_OWNED_APP_OPTIONS} are refused.
165
+ # @return [MCPRackApp]
166
+ # @raise [ArgumentError] on a factory-owned option or invalid setting.
167
+ def user_scoped(session_token_from: nil, permissions: :readonly, client: nil,
168
+ tenant_from: nil, session_validation: :per_request,
169
+ session_revalidate_interval: DEFAULT_SESSION_REVALIDATE_INTERVAL,
170
+ agent_options: {}, **rack_options, &block)
171
+ raise ArgumentError, "MCPRackApp.user_scoped builds its own agent factory; do not pass a block" if block
172
+ if permissions.to_s == "admin"
173
+ # The admin tier skips the spend cap and score quantization, which
174
+ # are cost and inference controls meant for untrusted callers. Every
175
+ # signed-in user of this endpoint would inherit that exemption.
176
+ raise ArgumentError,
177
+ "MCPRackApp.user_scoped does not accept permissions: :admin; use :readonly or :write"
178
+ end
179
+ assert_factory_options!(:user_scoped, agent_options, rack_options)
180
+ extractor = session_token_from || DEFAULT_SESSION_TOKEN_FROM
181
+ raise ArgumentError, "session_token_from must respond to #call" unless extractor.respond_to?(:call)
182
+ if tenant_from && !tenant_from.respond_to?(:call)
183
+ raise ArgumentError, "tenant_from must respond to #call"
184
+ end
185
+ unless SESSION_VALIDATION_MODES.include?(session_validation)
186
+ raise ArgumentError,
187
+ "session_validation must be one of #{SESSION_VALIDATION_MODES.inspect} " \
188
+ "(got #{session_validation.inspect})"
189
+ end
190
+ options = agent_options.dup.freeze
191
+ limiters = Parse::Agent::MCPRackApp.principal_rate_limiters_for(options)
192
+
193
+ factory = lambda do |env|
194
+ token = extractor.call(env).to_s.strip
195
+ raise Parse::Agent::Unauthorized.new("Missing session token", reason: :missing_session) if token.empty?
196
+
197
+ parse_client = client || Parse.client
198
+ user_id = begin
199
+ validate_session!(parse_client, token, mode: session_validation)
200
+ rescue SessionCheckUnavailable
201
+ # Fail closed for the request, but keep the token cached: the
202
+ # session may well be valid once Parse Server answers again.
203
+ raise Parse::Agent::Unauthorized.new("Session could not be verified",
204
+ reason: :session_check_unavailable)
205
+ end
206
+ # The verified user id is the session's principal, so a refreshed
207
+ # token (or a second login) for the same user keeps its sessions,
208
+ # and per-principal bounds apply per user rather than per token.
209
+ env[USER_PRINCIPAL_ENV_KEY] = user_id
210
+ kwargs = options.merge(session_token: token, permissions: permissions)
211
+ kwargs[:client] = client if client
212
+ kwargs[:rate_limiter] = limiters.fetch("user:#{user_id}") if limiters
213
+ if tenant_from
214
+ tenant = tenant_from.call(env, user_id)
215
+ if tenant.nil? || tenant.to_s.strip.empty?
216
+ raise Parse::Agent::Unauthorized.new("No tenant for session", reason: :missing_tenant)
217
+ end
218
+ kwargs[:tenant_id] = tenant
219
+ end
220
+ Parse::Agent.new(**kwargs)
221
+ end
222
+
223
+ # A session Parse Server reports invalid closes the stream at once.
224
+ # SessionCheckUnavailable (Parse Server unreachable, a 5xx) is left
225
+ # to propagate, so the stream's revalidation loop counts it as a
226
+ # transient error and only closes after repeated failures.
227
+ revalidator = lambda do |agent|
228
+ token = agent.respond_to?(:session_token) ? agent.session_token.to_s : ""
229
+ return false if token.empty?
230
+ validate_session!(client || Parse.client, token, mode: :per_request)
231
+ true
232
+ rescue Parse::Agent::Unauthorized
233
+ false
234
+ end
235
+
236
+ resolver = ->(_agent, env) { (uid = env[USER_PRINCIPAL_ENV_KEY]) ? "user:#{uid}" : nil }
237
+
238
+ new(agent_factory: factory,
239
+ principal_resolver: resolver,
240
+ listening_stream_revalidator: revalidator,
241
+ listening_stream_revalidate_interval: session_revalidate_interval,
242
+ **rack_options)
243
+ end
244
+
245
+ # Build an MCP endpoint whose agents use master authority, for
246
+ # analytics and trusted operational tools.
247
+ #
248
+ # `principal_resolver:` is required and must identify the operator
249
+ # behind each request (`->(agent, env) { "op:#{verified_id}" }`). It
250
+ # is used for session ownership (streams, approvals, cancellations)
251
+ # and audit; a request it cannot resolve (nil or blank) is refused
252
+ # with 401. Master authority governs what data the agent may reach;
253
+ # the agent's configured `tools:`, `classes:`, `filters:`, and field
254
+ # policy govern what it may actually do. Read-only by default.
255
+ #
256
+ # @param principal_resolver [#call] `->(agent, env) { principal }`.
257
+ # Required.
258
+ # @param permissions [Symbol] defaults to `:readonly`.
259
+ # @param client [Parse::Client, nil] a client configured with the
260
+ # master key. nil uses `Parse.client` at request time.
261
+ # @param tenant_from [#call, nil] `->(env, principal) { tenant }` to pin
262
+ # `tenant_id`; nil or blank is refused with 401.
263
+ # @param agent_options [Hash] extra `Parse::Agent.new` options.
264
+ # Identity and authority options are refused.
265
+ # @param rack_options [Hash] remaining `MCPRackApp.new` options.
266
+ # @return [MCPRackApp]
267
+ # @raise [ArgumentError] without a callable `principal_resolver`, or on
268
+ # a factory-owned option.
269
+ def master_analytics(principal_resolver:, permissions: :readonly, client: nil,
270
+ tenant_from: nil, agent_options: {}, **rack_options, &block)
271
+ raise ArgumentError, "MCPRackApp.master_analytics builds its own agent factory; do not pass a block" if block
272
+ unless principal_resolver.respond_to?(:call)
273
+ raise ArgumentError,
274
+ "MCPRackApp.master_analytics requires principal_resolver: (->(agent, env) { operator_id }). " \
275
+ "Without it every master-key agent shares one principal, so callers could attach to, " \
276
+ "approve, or cancel each other's sessions. For a deliberate single-operator endpoint, " \
277
+ "build MCPRackApp.new(agent_factory: ...) directly."
278
+ end
279
+ if tenant_from && !tenant_from.respond_to?(:call)
280
+ raise ArgumentError, "tenant_from must respond to #call"
281
+ end
282
+ assert_factory_options!(:master_analytics, agent_options, rack_options)
283
+ options = agent_options.dup.freeze
284
+ limiters = Parse::Agent::MCPRackApp.principal_rate_limiters_for(options)
285
+
286
+ # Resolve once per request and reuse it for ownership, so the
287
+ # resolver's identity check is not repeated (and cannot disagree
288
+ # with itself) between the factory and the fingerprint.
289
+ memo_key = "parse.mcp.analytics_principal"
290
+ resolver = lambda do |agent, env|
291
+ env.key?(memo_key) ? env[memo_key] : (env[memo_key] = principal_resolver.call(agent, env))
292
+ end
293
+
294
+ factory = lambda do |env|
295
+ parse_client = client || Parse.client
296
+ unless parse_client.respond_to?(:master_key) && !parse_client.master_key.to_s.empty?
297
+ raise Parse::Agent::Unauthorized.new("master_analytics requires a master-key client",
298
+ reason: :no_master_key)
299
+ end
300
+ kwargs = options.merge(permissions: permissions)
301
+ kwargs[:client] = client if client
302
+ agent = Parse::Agent.new(**kwargs)
303
+ principal = resolver.call(agent, env)
304
+ if principal.nil? || principal.to_s.strip.empty?
305
+ raise Parse::Agent::Unauthorized.new("Unidentified operator", reason: :missing_principal)
306
+ end
307
+ # The operator is known only once an agent exists (the resolver
308
+ # receives it), so rebuild this request's agent with the
309
+ # operator's shared limiter.
310
+ agent = Parse::Agent.new(**kwargs, rate_limiter: limiters.fetch("op:#{principal}")) if limiters
311
+ if tenant_from
312
+ tenant = tenant_from.call(env, principal)
313
+ if tenant.nil? || tenant.to_s.strip.empty?
314
+ raise Parse::Agent::Unauthorized.new("No tenant for operator", reason: :missing_tenant)
315
+ end
316
+ agent.tenant_id = tenant
317
+ end
318
+ agent
319
+ end
320
+
321
+ new(agent_factory: factory, principal_resolver: resolver, **rack_options)
322
+ end
323
+
324
+ # Validate a session token and return its user id, or raise
325
+ # {Parse::Agent::Unauthorized}.
326
+ #
327
+ # `:per_request` asks Parse Server directly (`GET /users/me`, response
328
+ # cache bypassed) and, on failure, evicts the token from the client's
329
+ # authorization identity cache so mongo-direct paths stop trusting it
330
+ # too. `:cached` resolves through `client.authorization` (identity
331
+ # cache, then `/users/me` on a miss).
332
+ #
333
+ # @api private
334
+ def validate_session!(parse_client, token, mode:)
335
+ if mode == :cached
336
+ begin
337
+ resolved = parse_client.authorization.resolve(token)
338
+ rescue Parse::Authorization::InvalidSession
339
+ raise Parse::Agent::Unauthorized.new("Invalid session", reason: :invalid_session)
340
+ end
341
+ user_id = resolved.respond_to?(:user_id) ? resolved.user_id : nil
342
+ raise Parse::Agent::Unauthorized.new("Invalid session", reason: :invalid_session) if user_id.to_s.empty?
343
+ return user_id.to_s
344
+ end
345
+
346
+ response = begin
347
+ parse_client.current_user(token, cache: false)
348
+ rescue Parse::Error::InvalidSessionTokenError
349
+ # Parse Server 9.x answers `/users/me` for a revoked, expired or
350
+ # unknown token with HTTP 400 and code 209, which the client
351
+ # raises as InvalidSessionTokenError. That is a definitive
352
+ # rejection of the session, not an outage: evict the token and
353
+ # refuse it. Treating it as an outage left a logged-out token
354
+ # in the identity cache and answered the caller with a retryable
355
+ # error instead of a 401.
356
+ evict_session!(parse_client, token)
357
+ raise Parse::Agent::Unauthorized.new("Invalid session", reason: :invalid_session)
358
+ rescue StandardError => e
359
+ raise SessionCheckUnavailable, "session check failed: #{e.class}"
360
+ end
361
+ if response.nil?
362
+ raise SessionCheckUnavailable, "session check returned no response"
363
+ end
364
+ if response.error? && !session_rejected?(response)
365
+ # Parse Server could not answer (5xx, timeout, an unexpected
366
+ # error code). That says nothing about the session, so it is
367
+ # neither accepted nor evicted from the identity cache.
368
+ raise SessionCheckUnavailable, "session check failed (#{response.http_status || response.code})"
369
+ end
370
+ result = response.error? ? nil : response.result
371
+ user_id = result.is_a?(Hash) ? (result["objectId"] || result[:objectId]) : nil
372
+ if user_id.to_s.empty?
373
+ evict_session!(parse_client, token)
374
+ raise Parse::Agent::Unauthorized.new("Invalid session", reason: :invalid_session)
375
+ end
376
+ user_id.to_s
377
+ end
378
+
379
+ private
380
+
381
+ # Forget a rejected token in the client's identity plane so
382
+ # mongo-direct paths stop trusting it too.
383
+ def evict_session!(parse_client, token)
384
+ if parse_client.respond_to?(:authorization) && parse_client.authorization.respond_to?(:invalidate)
385
+ parse_client.authorization.invalidate(token)
386
+ end
387
+ end
388
+
389
+ # True when Parse Server rejected the session itself (invalid or
390
+ # expired token, or an auth failure), as opposed to failing to answer.
391
+ def session_rejected?(response)
392
+ return true if response.respond_to?(:permission_denied?) && response.permission_denied?
393
+ code = response.respond_to?(:code) ? response.code : nil
394
+ code == Parse::Response::ERROR_INVALID_SESSION_TOKEN || code == 101
395
+ end
396
+
397
+ def assert_factory_options!(factory_name, agent_options, rack_options)
398
+ unless agent_options.is_a?(Hash)
399
+ raise ArgumentError, "#{factory_name}: agent_options must be a Hash"
400
+ end
401
+ owned = agent_options.keys.map(&:to_sym) & FACTORY_OWNED_AGENT_OPTIONS
402
+ unless owned.empty?
403
+ raise ArgumentError,
404
+ "#{factory_name}: agent_options may not set #{owned.inspect}; the factory owns " \
405
+ "identity and authority (use the factory's own keyword where one exists)"
406
+ end
407
+ if factory_name == :user_scoped
408
+ # A signed-in user's agent must not be handed authority beyond
409
+ # its session: master Atlas reach (unscoped $searchMeta counts)
410
+ # or the mutation override.
411
+ elevated = agent_options.keys.map(&:to_sym) & USER_SCOPED_REFUSED_AGENT_OPTIONS
412
+ unless elevated.empty?
413
+ raise ArgumentError,
414
+ "user_scoped: agent_options may not set #{elevated.inspect}; a user-scoped " \
415
+ "agent never receives authority beyond its session"
416
+ end
417
+ end
418
+ owned_app = rack_options.keys.map(&:to_sym) & FACTORY_OWNED_APP_OPTIONS
419
+ unless owned_app.empty?
420
+ raise ArgumentError, "#{factory_name}: #{owned_app.inspect} are set by the factory"
421
+ end
422
+ end
423
+ end
424
+ end
425
+ end
426
+ end