parse-stack-next 5.5.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -6
- data/README.md +26 -13
- data/bin/parse-console +9 -1
- data/docs/TEST_SERVER.md +115 -238
- data/docs/mcp_guide.md +1 -1
- data/docs/mongodb_index_optimization_guide.md +3 -2
- data/docs/usage_guide.md +1 -1
- data/docs/yard-template/default/fulldoc/html/css/common.css +52 -9
- data/docs/yard-template/default/fulldoc/html/css/full_list.css +40 -13
- data/lib/parse/agent/constraint_translator.rb +18 -18
- data/lib/parse/agent/errors.rb +29 -7
- data/lib/parse/agent/metadata_dsl.rb +6 -6
- data/lib/parse/agent/tools.rb +250 -59
- data/lib/parse/agent.rb +42 -30
- data/lib/parse/api/aggregate.rb +3 -3
- data/lib/parse/api/cloud_functions.rb +19 -10
- data/lib/parse/api/objects.rb +8 -8
- data/lib/parse/api/users.rb +9 -9
- data/lib/parse/atlas_search/session.rb +34 -34
- data/lib/parse/atlas_search.rb +243 -110
- data/lib/parse/client/body_builder.rb +10 -10
- data/lib/parse/client/logging.rb +5 -2
- data/lib/parse/client/profiling.rb +5 -2
- data/lib/parse/client/protocol.rb +1 -1
- data/lib/parse/client/url_redaction.rb +94 -0
- data/lib/parse/client.rb +43 -28
- data/lib/parse/embeddings/image_fetch.rb +6 -1
- data/lib/parse/embeddings/voyage.rb +16 -17
- data/lib/parse/live_query/client.rb +7 -7
- data/lib/parse/live_query/subscription.rb +1 -1
- data/lib/parse/lock.rb +1 -1
- data/lib/parse/lock_backend.rb +118 -2
- data/lib/parse/model/acl.rb +24 -24
- data/lib/parse/model/classes/job_schedule.rb +8 -8
- data/lib/parse/model/classes/job_status.rb +9 -9
- data/lib/parse/model/classes/role.rb +49 -49
- data/lib/parse/model/classes/session.rb +2 -2
- data/lib/parse/model/classes/user.rb +66 -66
- data/lib/parse/model/core/builder.rb +7 -7
- data/lib/parse/model/core/create_lock.rb +1 -1
- data/lib/parse/model/core/properties.rb +4 -4
- data/lib/parse/model/file.rb +57 -16
- data/lib/parse/model/model.rb +19 -19
- data/lib/parse/model/object.rb +38 -38
- data/lib/parse/model/pointer.rb +4 -4
- data/lib/parse/model/push.rb +5 -5
- data/lib/parse/mongodb.rb +84 -26
- data/lib/parse/pipeline_security.rb +2 -2
- data/lib/parse/query/constraints.rb +38 -38
- data/lib/parse/query.rb +151 -75
- data/lib/parse/retrieval/reranker/cohere.rb +30 -0
- data/lib/parse/schema.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -10
- data/lib/parse/two_factor_auth/user_extension.rb +25 -25
- data/lib/parse/webhooks/payload.rb +35 -35
- data/lib/parse/webhooks/registration.rb +2 -2
- data/lib/parse/webhooks/replay_protection.rb +16 -16
- data/lib/parse/webhooks.rb +11 -11
- data/parse-stack-next.gemspec +19 -1
- metadata +2 -38
- data/.bundle/config +0 -5
- data/.env.sample +0 -138
- data/.env.test +0 -10
- data/.github/ISSUE_TEMPLATE/bug_report.yml +0 -105
- data/.github/ISSUE_TEMPLATE/feature_request.yml +0 -67
- data/.github/dependabot.yml +0 -13
- data/.github/workflows/codeql.yml +0 -44
- data/.github/workflows/docs.yml +0 -39
- data/.github/workflows/release.yml +0 -43
- data/.github/workflows/ruby.yml +0 -38
- data/.gitignore +0 -56
- data/.ruby-version +0 -1
- data/.solargraph.yml +0 -22
- data/.vscode/settings.json +0 -3
- data/.yardopts +0 -19
- data/Gemfile +0 -43
- data/Gemfile.lock +0 -198
- data/Makefile +0 -63
- data/Rakefile +0 -825
- data/config/parse-config.json +0 -12
- data/scripts/debug-ips.js +0 -35
- data/scripts/docker/Dockerfile.parse +0 -17
- data/scripts/docker/atlas-init.js +0 -284
- data/scripts/docker/docker-compose.atlas.yml +0 -80
- data/scripts/docker/docker-compose.test.yml +0 -159
- data/scripts/docker/docker-compose.verifyemail.yml +0 -4
- data/scripts/docker/mongo-init.js +0 -21
- data/scripts/docker/preflight.sh +0 -76
- data/scripts/eval_mcp_with_lm_studio.rb +0 -274
- data/scripts/start-parse.sh +0 -154
- data/scripts/start_mcp_server.rb +0 -78
- data/scripts/test_server_connection.rb +0 -82
- data/scripts/vector_prototype/create_vector_index.js +0 -105
- data/scripts/vector_prototype/fetch_embeddings.py +0 -241
- data/scripts/vector_prototype/fixture_manifest.json +0 -9
- data/scripts/vector_prototype/query_prototype.rb +0 -84
- 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]
|
|
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
|
-
#
|
|
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
|
|
203
|
-
# module-boundary rescue and converted to
|
|
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]
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
972
|
-
# stack, but locking
|
|
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
|
|
984
|
-
#
|
|
985
|
-
#
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1448
|
-
# used to decode every query /
|
|
1449
|
-
#
|
|
1450
|
-
# (
|
|
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
|
|
1456
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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) —
|
|
1604
|
-
# always decodes the result. Use {Parse.call_function} with
|
|
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
|
-
|
|
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
|
|
14
|
-
#
|
|
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.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
|
340
|
-
#
|
|
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
|
|
436
|
-
#
|
|
437
|
-
# content row. Image inputs
|
|
438
|
-
# {
|
|
439
|
-
#
|
|
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
|
|
612
|
-
# from the client-supplied
|
|
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
|
|
615
|
-
#
|
|
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
|
-
#
|
|
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
|
|
672
|
-
# and (d) carry
|
|
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).
|
|
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.
|
|
240
|
+
return Parse::LockBackend.synchronize_process_mutex(store_key) do
|
|
241
241
|
yield SecureRandom.uuid
|
|
242
242
|
end
|
|
243
243
|
end
|
data/lib/parse/lock_backend.rb
CHANGED
|
@@ -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
|
-
|
|
197
|
-
@
|
|
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
|
data/lib/parse/model/acl.rb
CHANGED
|
@@ -178,8 +178,8 @@ module Parse
|
|
|
178
178
|
ACL::Permission.new(read, write)
|
|
179
179
|
end
|
|
180
180
|
|
|
181
|
-
# Build a MongoDB
|
|
182
|
-
# readable by any of
|
|
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
|
|
191
|
-
# missing
|
|
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
|
-
#
|
|
198
|
+
# `:ACL.readable_by` / `:ACL.readable_by_role` query
|
|
199
199
|
# constraints into aggregation pipelines.
|
|
200
|
-
# * {Parse::AtlasSearch} to enforce ACL on
|
|
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,
|
|
205
|
-
# @param include_public [Boolean] when
|
|
206
|
-
# appended to
|
|
207
|
-
# strict-permissions check (e.g.,
|
|
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
|
|
210
|
-
# inside a
|
|
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
|
|
219
|
-
# writable by any of
|
|
220
|
-
# the
|
|
221
|
-
# significance of the
|
|
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
|
|
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
|
-
#
|
|
232
|
+
# `"*"` appended when `include_public`) and returns the `$or`
|
|
233
233
|
# subexpression.
|
|
234
234
|
# @param include_missing [Boolean] when true (default), append the
|
|
235
|
-
#
|
|
236
|
-
#
|
|
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
|
|
239
|
-
# surface). When false and only the
|
|
240
|
-
# wrapper is dropped for a cleaner
|
|
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?("*")
|