parse-stack-next 5.5.3 → 5.5.5

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -4
  3. data/README.md +26 -13
  4. data/bin/parse-console +9 -1
  5. data/docs/TEST_SERVER.md +115 -238
  6. data/docs/mcp_guide.md +1 -1
  7. data/docs/mongodb_index_optimization_guide.md +3 -2
  8. data/docs/usage_guide.md +1 -1
  9. data/docs/yard-template/default/fulldoc/html/css/common.css +52 -9
  10. data/docs/yard-template/default/fulldoc/html/css/full_list.css +40 -13
  11. data/lib/parse/agent/constraint_translator.rb +18 -18
  12. data/lib/parse/agent/errors.rb +29 -7
  13. data/lib/parse/agent/metadata_dsl.rb +6 -6
  14. data/lib/parse/agent/tools.rb +250 -59
  15. data/lib/parse/agent.rb +42 -30
  16. data/lib/parse/api/aggregate.rb +3 -3
  17. data/lib/parse/api/cloud_functions.rb +19 -10
  18. data/lib/parse/api/objects.rb +8 -8
  19. data/lib/parse/api/users.rb +9 -9
  20. data/lib/parse/atlas_search/session.rb +34 -34
  21. data/lib/parse/atlas_search.rb +243 -110
  22. data/lib/parse/client/body_builder.rb +10 -10
  23. data/lib/parse/client/logging.rb +5 -2
  24. data/lib/parse/client/profiling.rb +5 -2
  25. data/lib/parse/client/protocol.rb +1 -1
  26. data/lib/parse/client/url_redaction.rb +94 -0
  27. data/lib/parse/client.rb +43 -28
  28. data/lib/parse/embeddings/image_fetch.rb +6 -1
  29. data/lib/parse/embeddings/voyage.rb +16 -17
  30. data/lib/parse/live_query/client.rb +7 -7
  31. data/lib/parse/live_query/subscription.rb +1 -1
  32. data/lib/parse/lock.rb +1 -1
  33. data/lib/parse/lock_backend.rb +118 -2
  34. data/lib/parse/model/acl.rb +24 -24
  35. data/lib/parse/model/classes/job_schedule.rb +8 -8
  36. data/lib/parse/model/classes/job_status.rb +9 -9
  37. data/lib/parse/model/classes/role.rb +49 -49
  38. data/lib/parse/model/classes/session.rb +2 -2
  39. data/lib/parse/model/classes/user.rb +66 -66
  40. data/lib/parse/model/core/builder.rb +7 -7
  41. data/lib/parse/model/core/create_lock.rb +1 -1
  42. data/lib/parse/model/core/properties.rb +4 -4
  43. data/lib/parse/model/file.rb +57 -16
  44. data/lib/parse/model/model.rb +19 -19
  45. data/lib/parse/model/object.rb +38 -38
  46. data/lib/parse/model/pointer.rb +4 -4
  47. data/lib/parse/model/push.rb +5 -5
  48. data/lib/parse/mongodb.rb +84 -26
  49. data/lib/parse/pipeline_security.rb +2 -2
  50. data/lib/parse/query/constraints.rb +38 -38
  51. data/lib/parse/query.rb +151 -75
  52. data/lib/parse/retrieval/reranker/cohere.rb +30 -0
  53. data/lib/parse/schema.rb +1 -1
  54. data/lib/parse/stack/version.rb +1 -1
  55. data/lib/parse/stack.rb +23 -10
  56. data/lib/parse/two_factor_auth/user_extension.rb +25 -25
  57. data/lib/parse/webhooks/payload.rb +35 -35
  58. data/lib/parse/webhooks/registration.rb +2 -2
  59. data/lib/parse/webhooks/replay_protection.rb +16 -16
  60. data/lib/parse/webhooks.rb +11 -11
  61. data/parse-stack-next.gemspec +19 -1
  62. metadata +2 -38
  63. data/.bundle/config +0 -5
  64. data/.env.sample +0 -138
  65. data/.env.test +0 -10
  66. data/.github/ISSUE_TEMPLATE/bug_report.yml +0 -105
  67. data/.github/ISSUE_TEMPLATE/feature_request.yml +0 -67
  68. data/.github/dependabot.yml +0 -13
  69. data/.github/workflows/codeql.yml +0 -44
  70. data/.github/workflows/docs.yml +0 -39
  71. data/.github/workflows/release.yml +0 -43
  72. data/.github/workflows/ruby.yml +0 -38
  73. data/.gitignore +0 -56
  74. data/.ruby-version +0 -1
  75. data/.solargraph.yml +0 -22
  76. data/.vscode/settings.json +0 -3
  77. data/.yardopts +0 -19
  78. data/Gemfile +0 -43
  79. data/Gemfile.lock +0 -198
  80. data/Makefile +0 -63
  81. data/Rakefile +0 -825
  82. data/config/parse-config.json +0 -12
  83. data/scripts/debug-ips.js +0 -35
  84. data/scripts/docker/Dockerfile.parse +0 -17
  85. data/scripts/docker/atlas-init.js +0 -284
  86. data/scripts/docker/docker-compose.atlas.yml +0 -80
  87. data/scripts/docker/docker-compose.test.yml +0 -159
  88. data/scripts/docker/docker-compose.verifyemail.yml +0 -4
  89. data/scripts/docker/mongo-init.js +0 -21
  90. data/scripts/docker/preflight.sh +0 -76
  91. data/scripts/eval_mcp_with_lm_studio.rb +0 -274
  92. data/scripts/start-parse.sh +0 -154
  93. data/scripts/start_mcp_server.rb +0 -78
  94. data/scripts/test_server_connection.rb +0 -82
  95. data/scripts/vector_prototype/create_vector_index.js +0 -105
  96. data/scripts/vector_prototype/fetch_embeddings.py +0 -241
  97. data/scripts/vector_prototype/fixture_manifest.json +0 -9
  98. data/scripts/vector_prototype/query_prototype.rb +0 -84
  99. data/scripts/vector_prototype/run.sh +0 -34
@@ -0,0 +1,94 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Middleware
6
+ # Shared query-string credential redactor for the logging and
7
+ # profiling middlewares. Kept in ONE place so the two sanitizers
8
+ # cannot drift — a credential that leaks through one but not the
9
+ # other is exactly the failure mode this consolidation prevents.
10
+ #
11
+ # The previous per-middleware sanitizer only redacted three exact
12
+ # param names (`sessionToken` / `masterKey` / `apiKey`). A credential
13
+ # carried under any other name — `token`, `access_token`,
14
+ # `client_secret`, `password`, a CloudFront `Signature` /
15
+ # `Key-Pair-Id`, an S3 `X-Amz-Signature`, etc. — sailed through into
16
+ # logs and `Parse.recent_profiles`. This redacts the VALUE of any
17
+ # query param whose name looks credential-bearing, while leaving the
18
+ # known-safe Parse query params (and other non-sensitive params)
19
+ # visible for debuggability.
20
+ module URLRedaction
21
+ # Parse (and a couple of CloudFront) query params that contain a
22
+ # sensitive-looking substring but are NOT secrets — protect them
23
+ # from over-redaction. Compared case-insensitively.
24
+ SAFE_QUERY_PARAMS = %w[
25
+ keys
26
+ excludekeys
27
+ redirectclassnameforkey
28
+ ].freeze
29
+
30
+ # A query param whose (case-insensitive) name matches this is
31
+ # treated as credential-bearing and has its value replaced with
32
+ # +[FILTERED]+, unless the name is in {SAFE_QUERY_PARAMS}.
33
+ SENSITIVE_NAME = /(?:token|key|secret|password|passwd|pwd|signature|\bsig\b|auth|credential|policy)/i
34
+
35
+ REDACTED = "[FILTERED]"
36
+
37
+ module_function
38
+
39
+ # @param url [String, #to_s] a request URL (with or without a query string)
40
+ # @return [String] the URL with credential-bearing query values redacted
41
+ def sanitize(url)
42
+ str = url.to_s
43
+ # Possessive quantifiers (`++` / `*+`) so the param-name and value
44
+ # runs never backtrack: on a pathological URL (a long run of
45
+ # non-delimiter chars with no `=`) the greedy form would rescan
46
+ # super-linearly (polynomial ReDoS on caller-supplied URLs flowing
47
+ # into log/profile redaction). The excluded-delimiter classes make
48
+ # the match unambiguous, so possessive matching is identical to the
49
+ # greedy match for every real query string.
50
+ str.gsub(/([?&])([^=&#]++)=([^&#]*+)/) do
51
+ sep = Regexp.last_match(1)
52
+ name = Regexp.last_match(2)
53
+ value = Regexp.last_match(3)
54
+ if sensitive?(name)
55
+ "#{sep}#{name}=#{REDACTED}"
56
+ else
57
+ "#{sep}#{name}=#{value}"
58
+ end
59
+ end
60
+ end
61
+
62
+ # @param name [String] a raw (possibly percent-encoded) query-param name
63
+ # @return [Boolean]
64
+ def sensitive?(name)
65
+ decoded = decode_name(name)
66
+ return false if SAFE_QUERY_PARAMS.include?(decoded.downcase)
67
+ !SENSITIVE_NAME.match(decoded).nil?
68
+ end
69
+
70
+ # Percent-decode a query-param NAME so a credential carried under an
71
+ # encoded name is matched by its decoded spelling. Servers decode
72
+ # `session%54oken` to `sessionToken` before reading it, so matching
73
+ # the raw name alone lets an encoded credential name slip past
74
+ # redaction.
75
+ #
76
+ # ONLY ASCII escapes (`%00`–`%7F`, i.e. first hex digit 0–7) are
77
+ # decoded. Credential keywords are ASCII, so that is sufficient — and
78
+ # decoding a high byte (`%C3`, `%FF`) would splice a lone
79
+ # continuation/lead byte into the (UTF-8) name and yield an
80
+ # invalid-encoding string that raises in the downstream `#downcase` /
81
+ # `#match`. High-byte escapes are therefore left literal. `+` and
82
+ # other bytes are untouched (param names don't use form-encoding).
83
+ # The decoded form is used ONLY for the sensitivity decision;
84
+ # {.sanitize} still emits the original spelling.
85
+ #
86
+ # @param name [String] a raw query-param name
87
+ # @return [String] the ASCII-percent-decoded name (always valid if
88
+ # +name+ was)
89
+ def decode_name(name)
90
+ name.gsub(/%([0-7][0-9A-Fa-f])/) { Regexp.last_match(1).to_i(16).chr }
91
+ end
92
+ end
93
+ end
94
+ end
data/lib/parse/client.rb CHANGED
@@ -183,7 +183,7 @@ module Parse
183
183
  # application_id or REST key. A server with a mistyped app_id still returns
184
184
  # `true` here; use {.connected?} when you also need to validate credentials.
185
185
  # @param conn [Symbol] the named client connection to probe. Defaults to :default.
186
- # @return [Boolean] +true+ if the server responded with status "ok", +false+
186
+ # @return [Boolean] `true` if the server responded with status "ok", `false`
187
187
  # if the server is unreachable or returned an unexpected response.
188
188
  def self.reachable?(conn = :default)
189
189
  client(conn).reachable?
@@ -193,18 +193,18 @@ module Parse
193
193
 
194
194
  # Check that the Parse Server is reachable and responding.
195
195
  # By default this probes the credential-less health endpoint, so it returns
196
- # +true+ whenever the server is up (it does NOT, by itself, validate the
196
+ # `true` whenever the server is up (it does NOT, by itself, validate the
197
197
  # application_id or REST key — see {Parse::Client#connected?} for why a
198
198
  # data-class probe is unreliable on a hardened server). Pass an `endpoint`
199
199
  # (e.g. `"classes/_User"`) to additionally exercise the auth stack against a
200
200
  # class you know the configured key can read; a bad key then surfaces as an
201
201
  # {Parse::Error::AuthenticationError} at the instance level, which the
202
- # instance method converts to +false+. Any other failure is caught by this
203
- # module-boundary rescue and converted to +false+.
202
+ # instance method converts to `false`. Any other failure is caught by this
203
+ # module-boundary rescue and converted to `false`.
204
204
  # @param conn [Symbol] the named client connection to probe. Defaults to :default.
205
205
  # @param endpoint [String, nil] optional path to probe instead of the health
206
206
  # endpoint, to validate credentials against a readable class.
207
- # @return [Boolean] +true+ if the server is reachable (and, when an endpoint
207
+ # @return [Boolean] `true` if the server is reachable (and, when an endpoint
208
208
  # is given, the credentials are accepted).
209
209
  def self.connected?(conn = :default, endpoint = nil)
210
210
  client(conn).connected?(endpoint)
@@ -349,7 +349,7 @@ module Parse
349
349
 
350
350
  # A NEW non-master {Parse::Client} that mirrors THIS client's connection
351
351
  # settings (`server_url` / `application_id` / `api_key`) but carries no
352
- # master key and binds +session_token+, so it acts on the server as that
352
+ # master key and binds `session_token`, so it acts on the server as that
353
353
  # user (ACL / CLP / `protectedFields` enforced, no master-key fallback).
354
354
  # This is the general primitive behind {Parse::Webhooks::Payload#user_client}
355
355
  # and {Parse::User#session_client}: derive a user-scoped client from a
@@ -952,11 +952,11 @@ module Parse
952
952
  end
953
953
 
954
954
  # No-credentials liveness probe. Hits the Parse Server health endpoint and
955
- # returns +true+ when the server responds with status "ok". No application
955
+ # returns `true` when the server responds with status "ok". No application
956
956
  # credentials are required, so this passes even when the configured
957
957
  # application_id or REST key is wrong. Use {#connected?} to also validate
958
958
  # credentials.
959
- # @return [Boolean] +true+ if the server is up and returned a healthy status.
959
+ # @return [Boolean] `true` if the server is up and returned a healthy status.
960
960
  def reachable?
961
961
  response = request(:get, Parse::API::Server::SERVER_HEALTH_PATH, opts: { cache: false })
962
962
  response.success?
@@ -965,11 +965,11 @@ module Parse
965
965
  end
966
966
 
967
967
  # Connectivity probe. By default hits the Parse Server health endpoint —
968
- # the same target as {#reachable?} — so it returns +true+ whenever the
968
+ # the same target as {#reachable?} — so it returns `true` whenever the
969
969
  # server is up, regardless of CLP configuration.
970
970
  #
971
- # Why not a `_User` find? A limit-0 find against +_User+ exercises the auth
972
- # stack, but locking +_User+ finds to the master key via a Class-Level
971
+ # Why not a `_User` find? A limit-0 find against `_User` exercises the auth
972
+ # stack, but locking `_User` finds to the master key via a Class-Level
973
973
  # Permission is standard production hardening — on such a server the probe
974
974
  # gets a permission error and (wrongly) reports "not connected" for a
975
975
  # perfectly healthy, correctly-configured deployment. The default therefore
@@ -980,12 +980,12 @@ module Parse
980
980
  # server, or one of your own readable classes). The probe runs `limit: 0`
981
981
  # so it never pulls rows, and routes through the auth middleware, so a wrong
982
982
  # application_id / REST key surfaces as an {Parse::Error::AuthenticationError}
983
- # and is converted to +false+. Any connection, timeout, or API error returns
984
- # +false+ rather than raising; genuine programming errors (e.g.
985
- # +NoMethodError+) still propagate.
983
+ # and is converted to `false`. Any connection, timeout, or API error returns
984
+ # `false` rather than raising; genuine programming errors (e.g.
985
+ # `NoMethodError`) still propagate.
986
986
  # @param endpoint [String, nil] optional path to probe instead of the
987
987
  # health endpoint, to validate credentials against a readable class.
988
- # @return [Boolean] +true+ if the server is reachable (and, when an endpoint
988
+ # @return [Boolean] `true` if the server is reachable (and, when an endpoint
989
989
  # is given, the credentials are accepted).
990
990
  def connected?(endpoint = nil)
991
991
  path = endpoint || Parse::API::Server::SERVER_HEALTH_PATH
@@ -1136,7 +1136,19 @@ module Parse
1136
1136
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1137
1137
  end
1138
1138
 
1139
- token = opts[:session_token]
1139
+ raw_token = opts[:session_token]
1140
+ # SEC-02: an EXPLICITLY-supplied session_token that is a blank /
1141
+ # whitespace-only string is an unusable credential — NOT an invitation
1142
+ # to fall back to the master key. Treat it as "no credential"
1143
+ # (anonymous) and fail closed: suppress the master key and send no
1144
+ # session header, so Parse Server applies public ACL/CLP instead of
1145
+ # silently executing with master authority. The caller passed a token
1146
+ # explicitly, so we also do NOT fall through to the ambient / bound
1147
+ # token — that was their stated (empty) scope. `session_token: nil`
1148
+ # (value literally nil) is unchanged: it means "not set", and still
1149
+ # resolves via the ambient / bound fallback below.
1150
+ explicit_blank_token = raw_token.is_a?(String) && raw_token.strip.empty?
1151
+ token = explicit_blank_token ? nil : raw_token
1140
1152
  # When no explicit token was passed AND the caller didn't ask to send
1141
1153
  # the master key, fall through to (in order) the fiber-local ambient set
1142
1154
  # by `Parse.with_session`, then this client's own bound `@session_token`.
@@ -1145,7 +1157,7 @@ module Parse
1145
1157
  # nested inside a `with_session(user)` block (or on a token-bound client)
1146
1158
  # would silently downgrade. The ambient wins over the bound token so a
1147
1159
  # `with_session` override inside a user-scoped client still takes effect.
1148
- if token.nil? && !(explicit_master && opts[:use_master_key] == true)
1160
+ if token.nil? && !explicit_blank_token && !(explicit_master && opts[:use_master_key] == true)
1149
1161
  ambient = Parse.current_session_token
1150
1162
  # A whitespace-only ambient must not count as present: otherwise it
1151
1163
  # blocks the bound-token fallback below and then fails the later
@@ -1153,7 +1165,10 @@ module Parse
1153
1165
  token = ambient if ambient.is_a?(String) && !ambient.strip.empty?
1154
1166
  token = @session_token if (token.nil? || token.to_s.strip.empty?) && @session_token
1155
1167
  end
1156
- if token.present?
1168
+ if explicit_blank_token
1169
+ # Fail closed: never send the master key for an unusable explicit token.
1170
+ headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1171
+ elsif token.present?
1157
1172
  token = token.session_token if token.respond_to?(:session_token)
1158
1173
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1159
1174
  headers[Parse::Protocol::SESSION_TOKEN] = token
@@ -1444,18 +1459,18 @@ module Parse
1444
1459
  #
1445
1460
  # SECURITY — cloud results are treated as server-authoritative. The
1446
1461
  # `__type:"Object"` decode in {._decode_cloud_value} routes through
1447
- # +Parse::Object.build+, which hydrates with trusted-init — the SAME path
1448
- # used to decode every query / +.fetch+ result. Trusted-init skips the
1449
- # +PROTECTED_INITIALIZE_KEYS+ filter, so credential-shaped keys
1450
- # (+sessionToken+, +authData+, +_rperm+, +_wperm+, +roles+, …) present in a
1462
+ # `Parse::Object.build`, which hydrates with trusted-init — the SAME path
1463
+ # used to decode every query / `.fetch` result. Trusted-init skips the
1464
+ # `PROTECTED_INITIALIZE_KEYS` filter, so credential-shaped keys
1465
+ # (`sessionToken`, `authData`, `_rperm`, `_wperm`, `roles`, …) present in a
1451
1466
  # cloud function's return value populate the in-memory object, exactly as they
1452
1467
  # do for any other server response. This is by design: the payload is authored
1453
1468
  # by your Cloud Code and the request is caller-authenticated, and making cloud
1454
1469
  # results filter these keys would make them inconsistent with (and stricter
1455
- # than) query/+.fetch+ hydration — e.g. a cloud function returning
1456
- # +request.user+ would come back missing its +sessionToken+. If a cloud
1470
+ # than) query/`.fetch` hydration — e.g. a cloud function returning
1471
+ # `request.user` would come back missing its `sessionToken`. If a cloud
1457
1472
  # function is expected to echo back third-party-influenced data, call it with
1458
- # +raw: true+ (+Parse.call_function(name, body, raw: true)+) to receive the
1473
+ # `raw: true` (`Parse.call_function(name, body, raw: true)`) to receive the
1459
1474
  # undecoded response and sanitize it yourself before building objects.
1460
1475
  def self._extract_cloud_result(response)
1461
1476
  r = response.result
@@ -1568,7 +1583,7 @@ module Parse
1568
1583
  # @option opts [Boolean] :raw Whether to return the raw response object.
1569
1584
  # @option opts [Boolean] :master_key Whether to use the master key for this request.
1570
1585
  # @option opts [Hash, nil] :context An optional caller context forwarded as the
1571
- # +X-Parse-Cloud-Context+ header. Parse Server maps it to +req.info.context+
1586
+ # `X-Parse-Cloud-Context` header. Parse Server maps it to `req.info.context`
1572
1587
  # in the function handler and flows it through beforeSave/afterSave triggers.
1573
1588
  # @return [Object] the result data of the response. nil if there was an error.
1574
1589
  def self.call_function(name, body = {}, **opts)
@@ -1600,8 +1615,8 @@ module Parse
1600
1615
  # specific {Parse::Error} subclasses as the underlying client does.
1601
1616
  # @param name (see Parse.call_function)
1602
1617
  # @param body (see Parse.call_function)
1603
- # @param opts (see Parse.call_function) — +:raw+ has no effect; this method
1604
- # always decodes the result. Use {Parse.call_function} with +raw: true+ if
1618
+ # @param opts (see Parse.call_function) — `:raw` has no effect; this method
1619
+ # always decodes the result. Use {Parse.call_function} with `raw: true` if
1605
1620
  # you need the undecoded response.
1606
1621
  # @raise [Parse::Error::CloudCodeError] when the response indicates a cloud-code error.
1607
1622
  # @return [Object] the result data of the response.
@@ -141,7 +141,12 @@ module Parse
141
141
  canonical = Parse::Embeddings.validate_image_url!(
142
142
  url, allow_insecure: allow_insecure, mode: :fetch,
143
143
  )
144
- io = Parse::File.safe_open_url(canonical)
144
+ # Push `max_bytes` INTO the fetch so the download aborts mid-stream
145
+ # once the cap is exceeded, rather than buffering the whole
146
+ # (globally-capped) body first and rejecting after. The post-read
147
+ # check below is retained as belt-and-suspenders for transports
148
+ # that can't enforce the streaming cap.
149
+ io = Parse::File.safe_open_url(canonical, max_bytes: max_bytes)
145
150
  begin
146
151
  bytes = io.read
147
152
  ensure
@@ -10,8 +10,8 @@ module Parse
10
10
  module Embeddings
11
11
  # Voyage AI embeddings provider. Wraps `POST /v1/embeddings` for
12
12
  # text-only models and `POST /v1/multimodalembeddings` for the
13
- # multimodal text+image models (text-input path only in v5.0; the
14
- # image-input path lands with {Provider#embed_image} in v5.1).
13
+ # multimodal text+image models (text via {#embed_text}, images via
14
+ # {#embed_image}).
15
15
  #
16
16
  # Supported models:
17
17
  #
@@ -23,13 +23,13 @@ module Parse
23
23
  # `voyage-code-3`.
24
24
  # * **domain models** — `voyage-finance-2`, `voyage-law-2`.
25
25
  # * **multimodal** — `voyage-multimodal-3` (1024-dim). Unified
26
- # text+image vector space at the network boundary. This provider
27
- # exposes the text-input path only: routes to
28
- # `/v1/multimodalembeddings` with a `{ inputs: [{ content:
29
- # [{ type: "text", text: … }] }] }` envelope. The same model will
30
- # accept image inputs in v5.1 when the `embed_image` hook ships;
31
- # text vectors stored today will sit in the same space as the
32
- # eventual image vectors (no re-embed required).
26
+ # text+image vector space at the network boundary. Text inputs
27
+ # route to `/v1/multimodalembeddings` with a `{ inputs: [{ content:
28
+ # [{ type: "text", text: … }] }] }` envelope; image inputs go
29
+ # through {#embed_image} as `image_url` rows (provider-side fetch,
30
+ # v5.1) or `image_base64` rows (SDK-fetched bytes, v5.5). Text and
31
+ # image vectors share the same space, so stored text vectors are
32
+ # comparable against image vectors without re-embedding.
33
33
  #
34
34
  # @example registration
35
35
  # Parse::Embeddings.register(:voyage,
@@ -336,8 +336,8 @@ module Parse
336
336
  # Voyage caps multimodal requests at the same per-request size
337
337
  # as the text endpoint. The text path goes through
338
338
  # `embed_text_batched` which chunks automatically; the image
339
- # path has no chunker yet (every directive is a single URL in
340
- # v5.1), so guard the direct-API caller against a silent 400.
339
+ # path has no chunker yet (every directive is a single image
340
+ # source), so guard the direct-API caller against a silent 400.
341
341
  if sources.length > @embed_batch_size
342
342
  raise ArgumentError,
343
343
  "Parse::Embeddings::Voyage#embed_image: batch size #{sources.length} exceeds " \
@@ -432,12 +432,11 @@ module Parse
432
432
  body
433
433
  end
434
434
 
435
- # Build the wire body for `/v1/multimodalembeddings`. The text
436
- # path wraps each input string as a single `{type: "text", text:}`
437
- # content row. Image inputs will land in v5.1 alongside
438
- # {Provider#embed_image}; for now the provider is text-only and
439
- # the multimodal envelope's `content` array always contains a
440
- # single text row per input.
435
+ # Build the wire body for `/v1/multimodalembeddings` for TEXT
436
+ # inputs: each string wraps as a single `{type: "text", text:}`
437
+ # content row. Image inputs build their content rows inline in
438
+ # {#embed_image} (`image_url` / `image_base64`) and do not pass
439
+ # through here.
441
440
  def build_multimodal_body(strings, wire_input_type)
442
441
  body = {
443
442
  inputs: strings.map { |s| { content: [{ type: "text", text: s }] } },
@@ -608,11 +608,11 @@ module Parse
608
608
  end
609
609
  end
610
610
 
611
- # RFC 6455 §1.3 magic GUID used to derive +Sec-WebSocket-Accept+
612
- # from the client-supplied +Sec-WebSocket-Key+. Server proves it
611
+ # RFC 6455 §1.3 magic GUID used to derive `Sec-WebSocket-Accept`
612
+ # from the client-supplied `Sec-WebSocket-Key`. Server proves it
613
613
  # spoke WebSocket (and not e.g. a confused HTTP/1.1 server that
614
- # happens to return +HTTP/1.1 101+) by echoing
615
- # +Base64(SHA1(key || GUID))+ back to the client.
614
+ # happens to return `HTTP/1.1 101`) by echoing
615
+ # `Base64(SHA1(key || GUID))` back to the client.
616
616
  WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
617
617
 
618
618
  # @!visibility private
@@ -625,7 +625,7 @@ module Parse
625
625
  HANDSHAKE_PER_LINE_BYTES = 8 * 1024
626
626
 
627
627
  # Perform WebSocket handshake. Verifies the server's
628
- # +Sec-WebSocket-Accept+ matches the SHA-1 of the random key +
628
+ # `Sec-WebSocket-Accept` matches the SHA-1 of the random key +
629
629
  # the WebSocket magic GUID, preventing cross-protocol acceptance
630
630
  # of any HTTP/1.1 101 response from a non-WebSocket server.
631
631
  def perform_handshake(host, path)
@@ -668,8 +668,8 @@ module Parse
668
668
  # @!visibility private
669
669
  # Parses and validates the server handshake response per RFC 6455 §4.1.
670
670
  # Refuses any response that does not (a) start with HTTP/1.1 101,
671
- # (b) carry +Upgrade: websocket+, (c) carry +Connection: Upgrade+,
672
- # and (d) carry +Sec-WebSocket-Accept: <expected>+ matching the
671
+ # (b) carry `Upgrade: websocket`, (c) carry `Connection: Upgrade`,
672
+ # and (d) carry `Sec-WebSocket-Accept: <expected>` matching the
673
673
  # client-derived value.
674
674
  def validate_handshake_response!(response, expected_accept)
675
675
  if response.empty?
@@ -74,7 +74,7 @@ module Parse
74
74
  attr_reader :session_token
75
75
 
76
76
  # @return [Array<String>, nil] field names that trigger update events when
77
- # changed (PS 7.0+ `watch` option). +nil+ means all field changes trigger
77
+ # changed (PS 7.0+ `watch` option). `nil` means all field changes trigger
78
78
  # update events.
79
79
  attr_reader :watch
80
80
 
data/lib/parse/lock.rb CHANGED
@@ -237,7 +237,7 @@ module Parse
237
237
  # signature across the Redis and degraded paths. There is no
238
238
  # cross-process owner here — the Mutex IS the exclusion — so a
239
239
  # fresh UUID is purely for signature parity / local fencing.
240
- return Parse::LockBackend.process_mutex(store_key).synchronize do
240
+ return Parse::LockBackend.synchronize_process_mutex(store_key) do
241
241
  yield SecureRandom.uuid
242
242
  end
243
243
  end
@@ -40,6 +40,14 @@ module Parse
40
40
  # acquisitions are silent.
41
41
  DEGRADED_WARNING_THROTTLE_SECONDS = 60
42
42
 
43
+ # Upper bound on the in-process Mutex registry (degraded fallback
44
+ # path). Without a cap, every distinct lock key leaks a permanent
45
+ # Mutex — a memory-exhaustion vector when keys are high-cardinality
46
+ # or attacker-influenced. Real deployments use a handful of lock
47
+ # keys, so this ceiling is far above normal use and only bites a
48
+ # runaway/abusive caller.
49
+ PROCESS_MUTEX_REGISTRY_MAX = 4096
50
+
43
51
  class << self
44
52
  # Find the Moneta store the lock should write through. Resolved
45
53
  # at call time (not memoized) so a test or an operator can swap
@@ -182,22 +190,129 @@ module Parse
182
190
  DEFAULT_POLL_BASE + (rand * 2 - 1) * DEFAULT_POLL_JITTER
183
191
  end
184
192
 
193
+ # Run `block` while holding the per-key in-process Mutex for the
194
+ # degraded fallback path, safe against registry eviction. This is
195
+ # the API callers should use — NOT the bare {.process_mutex}
196
+ # accessor.
197
+ #
198
+ # {.process_mutex} returns an *unlocked* Mutex; there is a window
199
+ # between the accessor returning and the caller reaching
200
+ # `Mutex#synchronize`. Under registry saturation the approximate-LRU
201
+ # eviction could observe that Mutex as unlocked and reclaim it, after
202
+ # which a second caller mints a DISTINCT Mutex for the same key —
203
+ # splitting mutual exclusion. This method closes the window by
204
+ # registering the caller as a *pending acquirer* inside the same
205
+ # critical section that hands out the Mutex, and eviction never
206
+ # reclaims a key with pending acquirers (or a locked Mutex).
207
+ #
208
+ # @param key [String] cache key.
209
+ # @yield executed while holding the per-key Mutex.
210
+ # @return the block's return value.
211
+ def synchronize_process_mutex(key, &block)
212
+ mutex = checkout_process_mutex(key)
213
+ begin
214
+ mutex.synchronize(&block)
215
+ ensure
216
+ checkin_process_mutex(key)
217
+ end
218
+ end
219
+
185
220
  # Per-key in-process Mutex registry for the degraded fallback
186
221
  # path. The first acquisition for a given key creates the
187
222
  # Mutex; subsequent acquisitions reuse it. Registry itself is
188
223
  # guarded by a tiny outer Mutex so two threads racing the
189
224
  # first acquisition of the same key get the same Mutex.
190
225
  #
226
+ # The registry is bounded at {PROCESS_MUTEX_REGISTRY_MAX} entries
227
+ # via approximate-LRU eviction: reused keys move to the MRU end,
228
+ # and when a new key would overflow the cap the oldest evictable
229
+ # mutexes are dropped first. A Mutex is evictable only when it is
230
+ # neither locked NOR reserved by a pending acquirer (see
231
+ # {.synchronize_process_mutex}); dropping either would let a
232
+ # concurrent acquisition mint a fresh Mutex for the same key and
233
+ # split mutual exclusion. Under pathological all-in-use saturation
234
+ # the registry may briefly exceed the cap rather than break
235
+ # correctness.
236
+ #
237
+ # NOTE: a Mutex handed back here is eligible for eviction until it is
238
+ # locked. Callers that lock it later (rather than immediately) must
239
+ # go through {.synchronize_process_mutex}, which reserves the key.
240
+ #
191
241
  # @param key [String] cache key.
192
242
  # @return [Mutex]
193
243
  def process_mutex(key)
244
+ @process_mutex_registry_lock ||= Mutex.new
245
+ @process_mutex_registry_lock.synchronize { registry_get_or_create(key) }
246
+ end
247
+
248
+ # @api private
249
+ # Atomically get-or-create the per-key Mutex AND reserve it for the
250
+ # caller (pending-acquirer count += 1) so eviction cannot reclaim it
251
+ # before the caller locks it. MUST be balanced by
252
+ # {.checkin_process_mutex} in an `ensure`.
253
+ #
254
+ # @param key [String] cache key.
255
+ # @return [Mutex]
256
+ def checkout_process_mutex(key)
194
257
  @process_mutex_registry_lock ||= Mutex.new
195
258
  @process_mutex_registry_lock.synchronize do
196
- @process_mutex_registry ||= {}
197
- @process_mutex_registry[key] ||= Mutex.new
259
+ mutex = registry_get_or_create(key)
260
+ pending = (@process_mutex_pending ||= Hash.new(0))
261
+ pending[key] += 1
262
+ mutex
198
263
  end
199
264
  end
200
265
 
266
+ # @api private
267
+ # Release a pending-acquirer reservation taken by
268
+ # {.checkout_process_mutex}.
269
+ #
270
+ # @param key [String] cache key.
271
+ # @return [void]
272
+ def checkin_process_mutex(key)
273
+ @process_mutex_registry_lock ||= Mutex.new
274
+ @process_mutex_registry_lock.synchronize do
275
+ pending = (@process_mutex_pending ||= Hash.new(0))
276
+ if pending.key?(key)
277
+ pending[key] -= 1
278
+ pending.delete(key) if pending[key] <= 0
279
+ end
280
+ end
281
+ end
282
+
283
+ # @api private
284
+ # Get-or-create + approximate-LRU eviction. MUST be called with
285
+ # `@process_mutex_registry_lock` already held (all three public
286
+ # entry points synchronize on it before calling in).
287
+ #
288
+ # @param key [String] cache key.
289
+ # @return [Mutex]
290
+ def registry_get_or_create(key)
291
+ reg = (@process_mutex_registry ||= {})
292
+
293
+ if (existing = reg[key])
294
+ # Move to the MRU end so the eviction scan treats it as
295
+ # recently used (Ruby Hashes preserve insertion order).
296
+ reg.delete(key)
297
+ reg[key] = existing
298
+ return existing
299
+ end
300
+
301
+ if reg.size >= PROCESS_MUTEX_REGISTRY_MAX
302
+ pending = (@process_mutex_pending ||= Hash.new(0))
303
+ reg.keys.each do |k|
304
+ break if reg.size < PROCESS_MUTEX_REGISTRY_MAX
305
+ m = reg[k]
306
+ # Evict only Mutexes that are neither locked nor reserved by a
307
+ # pending acquirer — either state means a caller is (about to
308
+ # be) inside it, and reclaiming would split mutual exclusion.
309
+ reg.delete(k) unless m.locked? || pending[k] > 0
310
+ end
311
+ end
312
+
313
+ reg[key] = Mutex.new
314
+ end
315
+
201
316
  # @return [Float] CLOCK_MONOTONIC seconds.
202
317
  def monotonic_now
203
318
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -211,6 +326,7 @@ module Parse
211
326
  @degraded_warned_at = nil
212
327
  @process_mutex_registry = nil
213
328
  @process_mutex_registry_lock = nil
329
+ @process_mutex_pending = nil
214
330
  @auto_secret = nil
215
331
  @plain_sha_warned = nil
216
332
  end
@@ -178,8 +178,8 @@ module Parse
178
178
  ACL::Permission.new(read, write)
179
179
  end
180
180
 
181
- # Build a MongoDB +$match+-shaped predicate that matches documents
182
- # readable by any of +permissions+. The canonical shape Parse
181
+ # Build a MongoDB `$match`-shaped predicate that matches documents
182
+ # readable by any of `permissions`. The canonical shape Parse
183
183
  # Server enforces at the REST/SDK layer:
184
184
  #
185
185
  # { "$or" => [
@@ -187,27 +187,27 @@ module Parse
187
187
  # { "_rperm" => { "$exists" => false } }
188
188
  # ]}
189
189
  #
190
- # The +$exists: false+ branch is mandatory. Parse Server treats a
191
- # missing +_rperm+ field as publicly readable (the field is only
190
+ # The `$exists: false` branch is mandatory. Parse Server treats a
191
+ # missing `_rperm` field as publicly readable (the field is only
192
192
  # written when an ACL exists), so dropping that branch silently
193
193
  # hides every public document.
194
194
  #
195
195
  # Used by:
196
196
  # * {Parse::Query::ACLReadableByConstraint} and
197
197
  # {Parse::Query::ACLReadableByRoleConstraint} to compile
198
- # +:ACL.readable_by+ / +:ACL.readable_by_role+ query
198
+ # `:ACL.readable_by` / `:ACL.readable_by_role` query
199
199
  # constraints into aggregation pipelines.
200
- # * {Parse::AtlasSearch} to enforce ACL on +$search+ pipelines
200
+ # * {Parse::AtlasSearch} to enforce ACL on `$search` pipelines
201
201
  # that bypass Parse Server and query MongoDB directly.
202
202
  #
203
203
  # @param permissions [Array<String>] permission strings — user
204
- # objectIds, +"role:RoleName"+, or +"*"+ for public.
205
- # @param include_public [Boolean] when +true+ (default), +"*"+ is
206
- # appended to +permissions+ if missing. Set +false+ for a
207
- # strict-permissions check (e.g., +:ACL.readable_by => "none"+
204
+ # objectIds, `"role:RoleName"`, or `"*"` for public.
205
+ # @param include_public [Boolean] when `true` (default), `"*"` is
206
+ # appended to `permissions` if missing. Set `false` for a
207
+ # strict-permissions check (e.g., `:ACL.readable_by => "none"`
208
208
  # semantics).
209
- # @return [Hash] a MongoDB +$or+ subexpression suitable for use
210
- # inside a +$match+ stage.
209
+ # @return [Hash] a MongoDB `$or` subexpression suitable for use
210
+ # inside a `$match` stage.
211
211
  # @example
212
212
  # permissions = ["*", user.id] + user.acl_roles.to_a.map { |n| "role:#{n}" }
213
213
  # pipeline << { "$match" => Parse::ACL.read_predicate(permissions) }
@@ -215,13 +215,13 @@ module Parse
215
215
  permission_predicate("_rperm", permissions, include_public: include_public, include_missing: include_missing)
216
216
  end
217
217
 
218
- # Build a MongoDB +$match+-shaped predicate that matches documents
219
- # writable by any of +permissions+. Mirrors {.read_predicate} on
220
- # the +_wperm+ field. See {.read_predicate} for the shape and the
221
- # significance of the +$exists: false+ branch.
218
+ # Build a MongoDB `$match`-shaped predicate that matches documents
219
+ # writable by any of `permissions`. Mirrors {.read_predicate} on
220
+ # the `_wperm` field. See {.read_predicate} for the shape and the
221
+ # significance of the `$exists: false` branch.
222
222
  # @param permissions [Array<String>] permission strings.
223
- # @param include_public [Boolean] whether to append +"*"+.
224
- # @return [Hash] a MongoDB +$or+ subexpression.
223
+ # @param include_public [Boolean] whether to append `"*"`.
224
+ # @return [Hash] a MongoDB `$or` subexpression.
225
225
  def self.write_predicate(permissions, include_public: true, include_missing: true)
226
226
  permission_predicate("_wperm", permissions, include_public: include_public, include_missing: include_missing)
227
227
  end
@@ -229,15 +229,15 @@ module Parse
229
229
  # @!visibility private
230
230
  # Shared implementation for {.read_predicate} and {.write_predicate}.
231
231
  # Normalizes the permissions array (string-coerced, deduplicated,
232
- # +"*"+ appended when +include_public+) and returns the +$or+
232
+ # `"*"` appended when `include_public`) and returns the `$or`
233
233
  # subexpression.
234
234
  # @param include_missing [Boolean] when true (default), append the
235
- # +{ field => { "$exists" => false } }+ branch so a missing
236
- # +_rperm+/+_wperm+ (treated as public by Parse Server) also matches.
235
+ # `{ field => { "$exists" => false } }` branch so a missing
236
+ # `_rperm`/`_wperm` (treated as public by Parse Server) also matches.
237
237
  # Set false for an EXACT match that requires the column to be present
238
- # and to contain one of +permissions+ (the strict/`readable_by_exact`
239
- # surface). When false and only the +$in+ branch remains, the +$or+
240
- # wrapper is dropped for a cleaner +{ field => { "$in" => perms } }+.
238
+ # and to contain one of `permissions` (the strict/`readable_by_exact`
239
+ # surface). When false and only the `$in` branch remains, the `$or`
240
+ # wrapper is dropped for a cleaner `{ field => { "$in" => perms } }`.
241
241
  def self.permission_predicate(field, permissions, include_public: true, include_missing: true)
242
242
  perms = Array(permissions).map(&:to_s).reject(&:empty?).uniq
243
243
  perms << "*" if include_public && !perms.include?("*")