parse-stack-next 5.2.1 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.bundle/config +1 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +616 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +296 -3
- data/Rakefile +243 -41
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +38 -0
- data/docs/mcp_guide.md +119 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +174 -9
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +200 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/pluralized_aliases.rb +30 -0
- data/lib/parse/model/core/properties.rb +27 -0
- data/lib/parse/model/core/querying.rb +73 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +173 -1
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +399 -11
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- metadata +16 -1
data/lib/parse/api/hooks.rb
CHANGED
|
@@ -7,15 +7,52 @@ module Parse
|
|
|
7
7
|
module Hooks
|
|
8
8
|
# @!visibility private
|
|
9
9
|
HOOKS_PREFIX = "hooks/"
|
|
10
|
-
# The allowed set of Parse triggers.
|
|
11
|
-
|
|
10
|
+
# The allowed set of Parse webhook triggers. Mirrors Parse Server's
|
|
11
|
+
# `triggers.Types` so registration of the auth / LiveQuery / password-
|
|
12
|
+
# reset hooks is no longer pre-rejected by the SDK.
|
|
13
|
+
#
|
|
14
|
+
# NOTE: this allowlist gates *registration* only. The webhook router in
|
|
15
|
+
# {Parse::Webhooks} currently shapes payloads for the object triggers
|
|
16
|
+
# (before/after save/delete/find); the login / connect / subscribe /
|
|
17
|
+
# password-reset payloads carry a different shape (no `object`) and
|
|
18
|
+
# their first-class routing is a follow-up. `beforeConnect` is a
|
|
19
|
+
# connection-global trigger whose Parse-canonical className is the
|
|
20
|
+
# `@Connect` sentinel; file triggers use `@File`. Both are accepted by
|
|
21
|
+
# the trigger-className validator ({Parse::API::PathSegment.trigger_class_name!}).
|
|
22
|
+
TRIGGER_NAMES = [
|
|
23
|
+
:afterDelete, :afterFind, :afterSave,
|
|
24
|
+
:beforeDelete, :beforeFind, :beforeSave,
|
|
25
|
+
:beforeLogin, :afterLogin, :afterLogout, :beforePasswordResetRequest,
|
|
26
|
+
:beforeConnect, :beforeSubscribe, :afterEvent,
|
|
27
|
+
].freeze
|
|
12
28
|
# @!visibility private
|
|
13
|
-
TRIGGER_NAMES_LOCAL = [
|
|
29
|
+
TRIGGER_NAMES_LOCAL = [
|
|
30
|
+
:after_delete, :after_find, :after_save,
|
|
31
|
+
:before_delete, :before_find, :before_save,
|
|
32
|
+
:before_login, :after_login, :after_logout, :before_password_reset_request,
|
|
33
|
+
:before_connect, :before_subscribe, :after_event,
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# `beforeCreate` / `afterCreate` are NOT Parse Server trigger types —
|
|
37
|
+
# Parse Server rejects them ("invalid hook declaration"). They exist only
|
|
38
|
+
# as Parse-Stack ActiveModel callbacks (`before_create` / `after_create`),
|
|
39
|
+
# which the webhook router runs INSIDE the `beforeSave` / `afterSave`
|
|
40
|
+
# handler for new objects (gated on `original.nil?`). So there is nothing
|
|
41
|
+
# to register for them — register `beforeSave` / `afterSave` instead and
|
|
42
|
+
# the create callbacks fire within it.
|
|
14
43
|
# @!visibility private
|
|
15
44
|
def _verify_trigger(triggerName)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
45
|
+
camel = triggerName.to_s.camelize(:lower).to_sym
|
|
46
|
+
if %i[beforeCreate afterCreate].include?(camel)
|
|
47
|
+
save = camel == :beforeCreate ? "beforeSave" : "afterSave"
|
|
48
|
+
callback = camel == :beforeCreate ? "before_create" : "after_create"
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"Parse Server has no #{camel} webhook trigger. Register a " \
|
|
51
|
+
"#{save} webhook instead — Parse Stack runs your #{callback} " \
|
|
52
|
+
"ActiveModel callbacks within the #{save} handler for new objects."
|
|
53
|
+
end
|
|
54
|
+
raise ArgumentError, "Invalid trigger name #{camel}" unless TRIGGER_NAMES.include?(camel)
|
|
55
|
+
camel
|
|
19
56
|
end
|
|
20
57
|
|
|
21
58
|
# Fetch all defined cloud code functions.
|
|
@@ -74,7 +111,7 @@ module Parse
|
|
|
74
111
|
# @see TRIGGER_NAMES
|
|
75
112
|
def fetch_trigger(triggerName, className)
|
|
76
113
|
triggerName = _verify_trigger(triggerName)
|
|
77
|
-
safe_class = Parse::API::PathSegment.
|
|
114
|
+
safe_class = Parse::API::PathSegment.trigger_class_name!(className, kind: "class name")
|
|
78
115
|
request :get, "#{HOOKS_PREFIX}triggers/#{safe_class}/#{triggerName}"
|
|
79
116
|
end
|
|
80
117
|
|
|
@@ -98,7 +135,7 @@ module Parse
|
|
|
98
135
|
# @see Parse::API::Hooks::TRIGGER_NAMES
|
|
99
136
|
def update_trigger(triggerName, className, url)
|
|
100
137
|
triggerName = _verify_trigger(triggerName)
|
|
101
|
-
safe_class = Parse::API::PathSegment.
|
|
138
|
+
safe_class = Parse::API::PathSegment.trigger_class_name!(className, kind: "class name")
|
|
102
139
|
request :put, "#{HOOKS_PREFIX}triggers/#{safe_class}/#{triggerName}", body: { url: url }
|
|
103
140
|
end
|
|
104
141
|
|
|
@@ -109,7 +146,7 @@ module Parse
|
|
|
109
146
|
# @see Parse::API::Hooks::TRIGGER_NAMES
|
|
110
147
|
def delete_trigger(triggerName, className)
|
|
111
148
|
triggerName = _verify_trigger(triggerName)
|
|
112
|
-
safe_class = Parse::API::PathSegment.
|
|
149
|
+
safe_class = Parse::API::PathSegment.trigger_class_name!(className, kind: "class name")
|
|
113
150
|
request :put, "#{HOOKS_PREFIX}triggers/#{safe_class}/#{triggerName}", body: { __op: "Delete" }
|
|
114
151
|
end
|
|
115
152
|
end
|
data/lib/parse/api/objects.rb
CHANGED
|
@@ -84,8 +84,15 @@ module Parse
|
|
|
84
84
|
# @param body [Hash] the body of the request.
|
|
85
85
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
86
86
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
87
|
+
# @param context [Hash, nil] an optional caller context forwarded as the
|
|
88
|
+
# +X-Parse-Cloud-Context+ header. Parse Server maps it to
|
|
89
|
+
# +req.info.context+ inside beforeSave/afterSave cloud triggers.
|
|
90
|
+
# Omit or pass +nil+ to leave behavior unchanged.
|
|
87
91
|
# @return [Parse::Response]
|
|
88
|
-
def create_object(className, body = {}, headers: {}, **opts)
|
|
92
|
+
def create_object(className, body = {}, headers: {}, context: nil, **opts)
|
|
93
|
+
unless context.nil?
|
|
94
|
+
headers = headers.merge(Parse::Protocol::CLOUD_CONTEXT => context.to_json)
|
|
95
|
+
end
|
|
89
96
|
response = request :post, uri_path(className), body: body, headers: headers, opts: opts
|
|
90
97
|
response.parse_class = className if response.present?
|
|
91
98
|
response
|
|
@@ -135,8 +142,15 @@ module Parse
|
|
|
135
142
|
# @param body [Hash] The key value pairs to update.
|
|
136
143
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
137
144
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
145
|
+
# @param context [Hash, nil] an optional caller context forwarded as the
|
|
146
|
+
# +X-Parse-Cloud-Context+ header. Parse Server maps it to
|
|
147
|
+
# +req.info.context+ inside beforeSave/afterSave cloud triggers.
|
|
148
|
+
# Omit or pass +nil+ to leave behavior unchanged.
|
|
138
149
|
# @return [Parse::Response]
|
|
139
|
-
def update_object(className, id, body = {}, headers: {}, **opts)
|
|
150
|
+
def update_object(className, id, body = {}, headers: {}, context: nil, **opts)
|
|
151
|
+
unless context.nil?
|
|
152
|
+
headers = headers.merge(Parse::Protocol::CLOUD_CONTEXT => context.to_json)
|
|
153
|
+
end
|
|
140
154
|
response = request :put, uri_path(className, id), body: body, headers: headers, opts: opts
|
|
141
155
|
response.parse_class = className if response.present?
|
|
142
156
|
response
|
|
@@ -45,6 +45,39 @@ module Parse
|
|
|
45
45
|
s
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# Parse trigger className pattern: a normal identifier, OR one of Parse
|
|
49
|
+
# Server's `@`-prefixed pseudo-classes (`@File` for file triggers,
|
|
50
|
+
# `@Connect` for the connection-global LiveQuery trigger). The optional
|
|
51
|
+
# leading `@` is the only relaxation; the rest stays path-safe (no `/`,
|
|
52
|
+
# `.`, or `..`).
|
|
53
|
+
TRIGGER_CLASS_PATTERN = /\A@?[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
54
|
+
|
|
55
|
+
# Validate a className used in a webhook-trigger path
|
|
56
|
+
# (`hooks/triggers/<className>/<trigger>`). Same as {.identifier!} but
|
|
57
|
+
# additionally accepts the `@File` / `@Connect` pseudo-classes that Parse
|
|
58
|
+
# Server uses for file and connection triggers. `create_trigger` carries
|
|
59
|
+
# the className in the request BODY (not the path), so it already accepts
|
|
60
|
+
# these; this keeps fetch / update / delete symmetric with create.
|
|
61
|
+
#
|
|
62
|
+
# @param value the className to validate.
|
|
63
|
+
# @param kind [String] human-readable name for error messages.
|
|
64
|
+
# @return [String] the validated className.
|
|
65
|
+
# @raise [ArgumentError] if blank or otherwise fails the pattern.
|
|
66
|
+
def trigger_class_name!(value, kind: "class name")
|
|
67
|
+
s = value.to_s
|
|
68
|
+
if s.empty?
|
|
69
|
+
raise ArgumentError, "#{kind} must not be empty"
|
|
70
|
+
end
|
|
71
|
+
unless TRIGGER_CLASS_PATTERN.match?(s)
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"#{kind} #{s.inspect} contains characters that are not allowed in " \
|
|
74
|
+
"a Parse trigger class name. Names must match " \
|
|
75
|
+
"/\\A@?[A-Za-z_][A-Za-z0-9_]*\\z/ (an identifier, optionally an " \
|
|
76
|
+
"@-prefixed pseudo-class such as @File or @Connect)."
|
|
77
|
+
end
|
|
78
|
+
s
|
|
79
|
+
end
|
|
80
|
+
|
|
48
81
|
# Validate and percent-encode a less-restrictive path segment, used
|
|
49
82
|
# for file names which can contain hyphens, periods, and other
|
|
50
83
|
# filename-safe characters but must never contain a literal `/`,
|
data/lib/parse/api/server.rb
CHANGED
|
@@ -59,6 +59,100 @@ module Parse
|
|
|
59
59
|
server_info.present? ? @server_info[:parseServerVersion] : nil
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# The `features` block advertised by `GET /serverInfo`. Parse Server
|
|
63
|
+
# surfaces coarse capability groups here (`globalConfig`, `hooks`,
|
|
64
|
+
# `cloudCode`, `logs`, `push`, `schemas`), each a Hash of booleans.
|
|
65
|
+
# This is authoritative where present but intentionally coarse — it
|
|
66
|
+
# does NOT carry fine-grained behavior flags like "public explain" or
|
|
67
|
+
# the LiveQuery `keys` rename. Those are resolved by version inference
|
|
68
|
+
# in {#server_supports?}.
|
|
69
|
+
# @return [Hash] the advertised features block, or `{}` if unavailable.
|
|
70
|
+
def server_features
|
|
71
|
+
info = server_info
|
|
72
|
+
return {} unless info.is_a?(Hash)
|
|
73
|
+
feats = info[:features]
|
|
74
|
+
feats.is_a?(Hash) ? feats : {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Capability table consumed by {#server_supports?}. Each entry is
|
|
78
|
+
# version-inferred (we cannot read these off the coarse `features`
|
|
79
|
+
# block) with one of two predicates:
|
|
80
|
+
#
|
|
81
|
+
# - `since:` — the capability EXISTS on this version and newer. An
|
|
82
|
+
# unknown/unparseable server version resolves to `true`
|
|
83
|
+
# (fail-open-to-modern: assume the current server line, matching the
|
|
84
|
+
# deprecation gate's posture).
|
|
85
|
+
# - `until:` — the capability existed BELOW this version and was
|
|
86
|
+
# removed/restricted at it. Unknown version resolves to `false`
|
|
87
|
+
# (the modern server no longer offers it).
|
|
88
|
+
#
|
|
89
|
+
# `feature:` (a `[group, flag]` pair) lets a future capability prefer
|
|
90
|
+
# the advertised `features` block when Parse Server genuinely surfaces
|
|
91
|
+
# it there; absent that, the version predicate decides.
|
|
92
|
+
# @!visibility private
|
|
93
|
+
CAPABILITIES = {
|
|
94
|
+
# LiveQuery subscription field projection: the `fields` option was
|
|
95
|
+
# renamed `keys` in Parse Server 7.0.0 (DEPPS9 / #8852). The SDK
|
|
96
|
+
# emits both, so this is informational rather than gating.
|
|
97
|
+
livequery_keys_option: { since: "7.0.0" },
|
|
98
|
+
# Cloud functions encode returned Parse.Object values as `__type`
|
|
99
|
+
# dictionaries: default flipped to `true` in 8.0.0, made
|
|
100
|
+
# unconditional (option removed) in 9.0.0.
|
|
101
|
+
cloud_object_encoding: { since: "8.0.0" },
|
|
102
|
+
# Non-master `explain` on a query: `allowPublicExplain` defaulted to
|
|
103
|
+
# `false` in 9.0.0, so a session-scoped explain that worked on 8.x
|
|
104
|
+
# is rejected on 9.x unless the operator re-enables it.
|
|
105
|
+
public_explain: { until: "9.0.0" },
|
|
106
|
+
# Aggregation `rawValues` / `rawFieldNames` options added in 9.9.0
|
|
107
|
+
# (#10438).
|
|
108
|
+
aggregate_raw_values: { since: "9.9.0" },
|
|
109
|
+
}.freeze
|
|
110
|
+
|
|
111
|
+
# Capability probe against the connected Parse Server. Builds on the
|
|
112
|
+
# already-memoized {#server_info} (no extra round-trip beyond the one
|
|
113
|
+
# `serverInfo` fetch) and the coarse `features` block, falling back to
|
|
114
|
+
# version inference for behavior flags the `features` block does not
|
|
115
|
+
# carry.
|
|
116
|
+
#
|
|
117
|
+
# Fails OPEN to the modern server line: when the server version cannot
|
|
118
|
+
# be determined (offline unit tests, a `serverInfo` outage, a wire
|
|
119
|
+
# surprise), a `since:` capability resolves `true` and an `until:`
|
|
120
|
+
# capability resolves `false` — i.e. "assume the current server",
|
|
121
|
+
# mirroring {#warn_if_deprecated_server_version!}.
|
|
122
|
+
#
|
|
123
|
+
# @example
|
|
124
|
+
# client.server_supports?(:public_explain) # => false on PS 9.x
|
|
125
|
+
# client.server_supports?(:aggregate_raw_values)
|
|
126
|
+
# @param feature [Symbol] a key of {CAPABILITIES}.
|
|
127
|
+
# @return [Boolean] whether the connected server supports the feature.
|
|
128
|
+
# @raise [ArgumentError] for an unknown capability key (typo guard).
|
|
129
|
+
def server_supports?(feature)
|
|
130
|
+
spec = CAPABILITIES[feature]
|
|
131
|
+
raise ArgumentError, "Unknown Parse Server capability #{feature.inspect}" if spec.nil?
|
|
132
|
+
|
|
133
|
+
# Prefer the advertised features block when a capability declares a
|
|
134
|
+
# `[group, flag]` path AND the server actually surfaces it.
|
|
135
|
+
if (path = spec[:feature])
|
|
136
|
+
group, flag = path
|
|
137
|
+
advertised = server_features.dig(group.to_s, flag.to_s)
|
|
138
|
+
advertised = server_features.dig(group, flag) if advertised.nil?
|
|
139
|
+
return advertised == true unless advertised.nil?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
version = server_version.to_s
|
|
143
|
+
if (floor = spec[:since])
|
|
144
|
+
# Supported on `floor` and newer. Unknown version => assume modern => true.
|
|
145
|
+
return true if version.empty?
|
|
146
|
+
!server_version_below?(version, floor)
|
|
147
|
+
elsif (ceiling = spec[:until])
|
|
148
|
+
# Supported strictly below `ceiling`. Unknown version => assume modern => false.
|
|
149
|
+
return false if version.empty?
|
|
150
|
+
server_version_below?(version, ceiling)
|
|
151
|
+
else
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
62
156
|
private
|
|
63
157
|
|
|
64
158
|
# One-shot deprecation warning. The check runs once per client
|
data/lib/parse/api/users.rb
CHANGED
|
@@ -14,7 +14,11 @@ module Parse
|
|
|
14
14
|
# @!visibility private
|
|
15
15
|
LOGIN_PATH = "login"
|
|
16
16
|
# @!visibility private
|
|
17
|
+
VERIFY_PASSWORD_PATH = "verifyPassword"
|
|
18
|
+
# @!visibility private
|
|
17
19
|
REQUEST_PASSWORD_RESET = "requestPasswordReset"
|
|
20
|
+
# @!visibility private
|
|
21
|
+
VERIFICATION_EMAIL_REQUEST = "verificationEmailRequest"
|
|
18
22
|
|
|
19
23
|
# Fetch a {Parse::User} for a given objectId.
|
|
20
24
|
# @param id [String] the user objectid
|
|
@@ -130,6 +134,26 @@ module Parse
|
|
|
130
134
|
response
|
|
131
135
|
end
|
|
132
136
|
|
|
137
|
+
# Request that Parse Server (re)send the email-address verification email
|
|
138
|
+
# for a registered, not-yet-verified user. Requires the server to have an
|
|
139
|
+
# email adapter and `verifyUserEmails` enabled; otherwise Parse Server
|
|
140
|
+
# responds with an error. Rate-limited per email like password reset.
|
|
141
|
+
#
|
|
142
|
+
# @param email [String] the Parse user email.
|
|
143
|
+
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
144
|
+
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
145
|
+
# @return [Parse::Response]
|
|
146
|
+
def request_email_verification(email, headers: {}, **opts)
|
|
147
|
+
rate_key = "emailverify:#{email}"
|
|
148
|
+
check_login_rate_limit!(rate_key)
|
|
149
|
+
body = { email: email }
|
|
150
|
+
response = request :post, VERIFICATION_EMAIL_REQUEST, body: body, opts: opts, headers: headers
|
|
151
|
+
# Indistinguishable found/not-found response, like password reset — count
|
|
152
|
+
# every attempt toward backoff so probing can't reset the counter.
|
|
153
|
+
track_login_attempt(rate_key, false)
|
|
154
|
+
response
|
|
155
|
+
end
|
|
156
|
+
|
|
133
157
|
# Login a user. Implements client-side rate limiting with exponential
|
|
134
158
|
# backoff after repeated failures to mitigate brute force attacks.
|
|
135
159
|
# @param username [String] the Parse user username.
|
|
@@ -180,6 +204,37 @@ module Parse
|
|
|
180
204
|
response
|
|
181
205
|
end
|
|
182
206
|
|
|
207
|
+
# Verify a user's credentials against Parse Server without minting a session.
|
|
208
|
+
# This is the canonical step-up / re-authentication primitive: it confirms
|
|
209
|
+
# that the username + password combination is correct without producing a
|
|
210
|
+
# new session token on success.
|
|
211
|
+
#
|
|
212
|
+
# Uses the +POST /parse/verifyPassword+ endpoint (credentials in the request
|
|
213
|
+
# BODY, mirroring +login+) rather than the +GET+ form. Parse Server accepts
|
|
214
|
+
# both (same handler, neither master-key gated; the POST variant landed in
|
|
215
|
+
# 7.1.0), but POST keeps the plaintext password out of the URL — and
|
|
216
|
+
# therefore out of server access logs, reverse-proxy logs, the +Referer+
|
|
217
|
+
# header, and the SDK's URL-keyed response cache.
|
|
218
|
+
#
|
|
219
|
+
# On success Parse Server returns the user object (HTTP 200) with the same
|
|
220
|
+
# shape as a login response (minus +sessionToken+). On failure it returns a
|
|
221
|
+
# 4xx with an error body, most commonly:
|
|
222
|
+
# - code 101 (+ERROR_OBJECT_NOT_FOUND+) for an unknown username or wrong password.
|
|
223
|
+
# - code 205 (+ERROR_EMAIL_NOT_FOUND+) when +preventLoginWithUnverifiedEmail+
|
|
224
|
+
# is enabled and the account's email has not been verified.
|
|
225
|
+
#
|
|
226
|
+
# @param username [String] the Parse user username.
|
|
227
|
+
# @param password [String] the Parse user's associated password.
|
|
228
|
+
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
229
|
+
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
230
|
+
# @return [Parse::Response]
|
|
231
|
+
def verify_password(username, password, headers: {}, **opts)
|
|
232
|
+
body = { username: username, password: password }
|
|
233
|
+
response = request :post, VERIFY_PASSWORD_PATH, body: body, headers: headers, opts: opts
|
|
234
|
+
response.parse_class = Parse::Model::CLASS_USER
|
|
235
|
+
response
|
|
236
|
+
end
|
|
237
|
+
|
|
183
238
|
# Logout a user by deleting the associated session.
|
|
184
239
|
# @param session_token [String] the Parse user session token to delete.
|
|
185
240
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
@@ -223,7 +278,7 @@ module Parse
|
|
|
223
278
|
LOGIN_RATE_LIMIT_TTL = 600
|
|
224
279
|
|
|
225
280
|
# Checks if a login attempt is allowed for the given username.
|
|
226
|
-
# @raise [
|
|
281
|
+
# @raise [Parse::Error::AccountLockoutError] if the account is temporarily locked out.
|
|
227
282
|
def check_login_rate_limit!(username)
|
|
228
283
|
@login_rate_limit_mutex ||= Mutex.new
|
|
229
284
|
@login_rate_limit_mutex.synchronize do
|
|
@@ -231,7 +286,8 @@ module Parse
|
|
|
231
286
|
return unless entry
|
|
232
287
|
if entry[:locked_until] && Time.now < entry[:locked_until]
|
|
233
288
|
wait = (entry[:locked_until] - Time.now).ceil
|
|
234
|
-
raise
|
|
289
|
+
raise Parse::Error::AccountLockoutError,
|
|
290
|
+
"Login rate limited for '#{username}'. Try again in #{wait} seconds."
|
|
235
291
|
end
|
|
236
292
|
end
|
|
237
293
|
end
|
data/lib/parse/atlas_search.rb
CHANGED
|
@@ -105,10 +105,10 @@ module Parse
|
|
|
105
105
|
|
|
106
106
|
# @!attribute [rw] role_cache_ttl
|
|
107
107
|
# TTL (seconds) for {Session}'s user-id → role-name cache.
|
|
108
|
-
# Default:
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
108
|
+
# Default: 30. Short on purpose: stale role data yields
|
|
109
|
+
# incorrect ACL decisions, so the cache is sized to amortize
|
|
110
|
+
# within a single request/turn but expire well inside the
|
|
111
|
+
# response time the operator notices a role grant or revoke.
|
|
112
112
|
# @return [Integer]
|
|
113
113
|
attr_accessor :role_cache_ttl
|
|
114
114
|
|
|
@@ -141,7 +141,7 @@ module Parse
|
|
|
141
141
|
# @param session_cache_ttl [Integer] session-token cache TTL
|
|
142
142
|
# (seconds). Default: 3600.
|
|
143
143
|
# @param role_cache_ttl [Integer] role-name cache TTL (seconds).
|
|
144
|
-
# Default:
|
|
144
|
+
# Default: 30.
|
|
145
145
|
# @example
|
|
146
146
|
# Parse::AtlasSearch.configure(enabled: true, default_index: "default")
|
|
147
147
|
def configure(enabled: true,
|
|
@@ -195,7 +195,7 @@ module Parse
|
|
|
195
195
|
@allow_raw = default_allow_raw
|
|
196
196
|
@require_session_token = false
|
|
197
197
|
@session_cache_ttl = 3600
|
|
198
|
-
@role_cache_ttl =
|
|
198
|
+
@role_cache_ttl = 30
|
|
199
199
|
@session_cache = Session::MemoryCache.new
|
|
200
200
|
@role_cache = Session::MemoryCache.new
|
|
201
201
|
@master_warned = false
|
|
@@ -1015,7 +1015,7 @@ module Parse
|
|
|
1015
1015
|
@allow_raw = nil
|
|
1016
1016
|
@require_session_token = false
|
|
1017
1017
|
@session_cache_ttl = 3600
|
|
1018
|
-
@role_cache_ttl =
|
|
1018
|
+
@role_cache_ttl = 30
|
|
1019
1019
|
@session_cache = Session::MemoryCache.new
|
|
1020
1020
|
@role_cache = Session::MemoryCache.new
|
|
1021
1021
|
@master_warned = false
|
|
@@ -74,6 +74,11 @@ module Parse
|
|
|
74
74
|
Parse::Protocol::MASTER_KEY,
|
|
75
75
|
Parse::Protocol::API_KEY,
|
|
76
76
|
Parse::Protocol::SESSION_TOKEN,
|
|
77
|
+
# Caller-supplied Cloud Code context (X-Parse-Cloud-Context) carries
|
|
78
|
+
# `context.to_json`, which may hold PII / request metadata. Redact it in
|
|
79
|
+
# the header log; the body/as_json log path scrubs sensitive sub-values
|
|
80
|
+
# of context separately.
|
|
81
|
+
Parse::Protocol::CLOUD_CONTEXT,
|
|
77
82
|
"X-Parse-JavaScript-Key",
|
|
78
83
|
"Authorization",
|
|
79
84
|
"Cookie",
|
|
@@ -31,6 +31,10 @@ module Parse
|
|
|
31
31
|
# The request header field for MongoDB read preference.
|
|
32
32
|
# Supported values: PRIMARY, PRIMARY_PREFERRED, SECONDARY, SECONDARY_PREFERRED, NEAREST
|
|
33
33
|
READ_PREFERENCE = "X-Parse-Read-Preference"
|
|
34
|
+
# The request header field for threading a caller-supplied context object
|
|
35
|
+
# through a write or cloud-function call. Parse Server maps this header to
|
|
36
|
+
# +req.info.context+ and flows it through beforeSave/afterSave triggers.
|
|
37
|
+
CLOUD_CONTEXT = "X-Parse-Cloud-Context"
|
|
34
38
|
|
|
35
39
|
# Valid read preference values for MongoDB
|
|
36
40
|
READ_PREFERENCES = %w[PRIMARY PRIMARY_PREFERRED SECONDARY SECONDARY_PREFERRED NEAREST].freeze
|
data/lib/parse/client.rb
CHANGED
|
@@ -331,7 +331,86 @@ module Parse
|
|
|
331
331
|
attr_accessor :cache
|
|
332
332
|
attr_writer :retry_limit
|
|
333
333
|
attr_reader :application_id, :api_key, :master_key, :server_url
|
|
334
|
+
# @return [String, nil] the session token bound to this client, if any
|
|
335
|
+
# (see the `:session_token` constructor option). Applied as the
|
|
336
|
+
# lowest-priority auth fallback on every request.
|
|
337
|
+
attr_reader :session_token
|
|
334
338
|
alias_method :app_id, :application_id
|
|
339
|
+
|
|
340
|
+
# Redacted inspection. The default Ruby `#inspect` would dump every ivar,
|
|
341
|
+
# exposing the master key and any bound session token in cleartext wherever
|
|
342
|
+
# a client is logged or surfaced in an error reporter. Show only the
|
|
343
|
+
# connection identity and a boolean for each credential's presence.
|
|
344
|
+
def inspect
|
|
345
|
+
"#<#{self.class.name} server_url=#{@server_url.inspect} " \
|
|
346
|
+
"app_id=#{@application_id.inspect} master_key=#{@master_key ? "[FILTERED]" : "nil"} " \
|
|
347
|
+
"session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# A NEW non-master {Parse::Client} that mirrors THIS client's connection
|
|
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
|
|
353
|
+
# user (ACL / CLP / `protectedFields` enforced, no master-key fallback).
|
|
354
|
+
# This is the general primitive behind {Parse::Webhooks::Payload#user_client}
|
|
355
|
+
# and {Parse::User#session_client}: derive a user-scoped client from a
|
|
356
|
+
# configured (e.g. master) client without re-specifying the connection.
|
|
357
|
+
#
|
|
358
|
+
# user_client = Parse.client.become(user.session_token)
|
|
359
|
+
# Parse::Query.new("Post", client: user_client).results # as the user
|
|
360
|
+
#
|
|
361
|
+
# @param session_token [String, #session_token] the token to bind. A blank
|
|
362
|
+
# token yields a tokenless non-master client (anonymous REST).
|
|
363
|
+
# @return [Parse::Client]
|
|
364
|
+
def become(session_token)
|
|
365
|
+
Parse::Client.new(
|
|
366
|
+
server_url: @server_url,
|
|
367
|
+
app_id: @application_id,
|
|
368
|
+
api_key: @api_key,
|
|
369
|
+
master_key: nil,
|
|
370
|
+
session_token: session_token,
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# A NEW anonymous client that mirrors THIS client's connection but carries
|
|
375
|
+
# neither a master key nor a session token — every request it makes is
|
|
376
|
+
# unauthenticated (app-id + REST key only). Use it to drop the bound user
|
|
377
|
+
# identity for a one-off public read without mutating a shared client.
|
|
378
|
+
# Equivalent to {#become} with no token.
|
|
379
|
+
# @return [Parse::Client]
|
|
380
|
+
def anonymous
|
|
381
|
+
become(nil)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Run a block with this client's bound {#session_token} active as the
|
|
385
|
+
# ambient session, so every query / object operation inside it that resolves
|
|
386
|
+
# the default client (e.g. `Post.count`, `Post.all`, `obj.save`) is
|
|
387
|
+
# authorized by Parse Server as that user — ACL and CLP enforced, master key
|
|
388
|
+
# suppressed — without threading `session_token:` through each call.
|
|
389
|
+
#
|
|
390
|
+
# This is the client-receiver flavor of {Parse.with_session} (and mirrors
|
|
391
|
+
# {Parse::User#with_session}); it scopes by binding the token as the AMBIENT
|
|
392
|
+
# session — it does not re-route operations through this client object, so
|
|
393
|
+
# the connection used inside the block is still the resolved default client.
|
|
394
|
+
# If you need operations to run against a different client, pass that client
|
|
395
|
+
# explicitly (e.g. `Parse::Query.new("Post", client: #{become}(...))`).
|
|
396
|
+
#
|
|
397
|
+
# total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
|
|
398
|
+
#
|
|
399
|
+
# Scopes REST-routed operations (`find` / `get` / `count` / `save`). It does
|
|
400
|
+
# NOT scope mongo-direct queries (`results_direct`, `aggregate`, Atlas
|
|
401
|
+
# search): those resolve auth from the query's own `session_token:` /
|
|
402
|
+
# `acl_user:` and, absent that, run in MASTER mode — so a mongo-direct read
|
|
403
|
+
# inside this block is a full master read, not anonymous. Scope mongo-direct
|
|
404
|
+
# explicitly with a per-query `session_token:` or a scoped {Parse::Agent}.
|
|
405
|
+
#
|
|
406
|
+
# @raise [ArgumentError] if this client has no bound session token (scoping
|
|
407
|
+
# would be a no-op and almost certainly a mistake).
|
|
408
|
+
# @return the value of the block.
|
|
409
|
+
def with_session(&block)
|
|
410
|
+
raise ArgumentError, "Parse::Client#with_session requires a block" unless block_given?
|
|
411
|
+
raise ArgumentError, "Parse::Client#with_session requires a client with a bound session_token" if @session_token.nil?
|
|
412
|
+
Parse.with_session(@session_token, &block)
|
|
413
|
+
end
|
|
335
414
|
# The client can support multiple sessions. The first session created, will be placed
|
|
336
415
|
# under the default session tag. The :default session will be the default client to be used
|
|
337
416
|
# by the other classes including Parse::Query and Parse::Objects
|
|
@@ -415,6 +494,14 @@ module Parse
|
|
|
415
494
|
# @option opts [String] :master_key The Parse application master key (optional).
|
|
416
495
|
# If this key is set, it will be sent on every request sent by the client
|
|
417
496
|
# and your models. Defaults to ENV['PARSE_SERVER_MASTER_KEY'].
|
|
497
|
+
# @option opts [String] :session_token An optional session token bound to
|
|
498
|
+
# this client. When set, every request that does not pass an explicit
|
|
499
|
+
# `session_token:` / `use_master_key: true` and is not inside a
|
|
500
|
+
# `Parse.with_session` block sends this token (and suppresses the master
|
|
501
|
+
# key), so the client transparently acts as that user. Precedence is
|
|
502
|
+
# explicit per-call > `Parse.with_session` ambient > this bound token.
|
|
503
|
+
# Typically paired with `master_key: nil` to build a user-scoped client
|
|
504
|
+
# (see {Parse::Webhooks::Payload#user_client}).
|
|
418
505
|
# @option opts [Boolean, Symbol] :logging Controls request/response logging.
|
|
419
506
|
# - `true` - Enable logging at :info level
|
|
420
507
|
# - `:debug` - Enable verbose logging with headers and body content
|
|
@@ -492,7 +579,26 @@ module Parse
|
|
|
492
579
|
@server_url = opts[:server_url] || ENV["PARSE_SERVER_URL"] || Parse::Protocol::SERVER_URL
|
|
493
580
|
@application_id = opts[:application_id] || opts[:app_id] || ENV["PARSE_SERVER_APPLICATION_ID"] || ENV["PARSE_APP_ID"]
|
|
494
581
|
@api_key = opts[:api_key] || opts[:rest_api_key] || ENV["PARSE_SERVER_REST_API_KEY"] || ENV["PARSE_API_KEY"]
|
|
495
|
-
|
|
582
|
+
# Distinguish an explicit `master_key: nil` (deliberately a non-master
|
|
583
|
+
# client — what user_client / session_client / user_agent rely on) from
|
|
584
|
+
# an omitted key (fall back to ENV). The previous `opts[:master_key] ||
|
|
585
|
+
# ENV[...]` form silently re-inherited the process master key for the
|
|
586
|
+
# explicit-nil case, putting a "non-master" client back into master mode
|
|
587
|
+
# in any deployment that exports PARSE_SERVER_MASTER_KEY / PARSE_MASTER_KEY.
|
|
588
|
+
@master_key = if opts.key?(:master_key)
|
|
589
|
+
opts[:master_key]
|
|
590
|
+
else
|
|
591
|
+
ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
|
|
592
|
+
end
|
|
593
|
+
# Optional token bound to this client; applied per request as the
|
|
594
|
+
# lowest-priority auth fallback (see #request). Normalize blank/whitespace
|
|
595
|
+
# to nil so it never trips the "token present" branch at request time
|
|
596
|
+
# (where `present?` is false for whitespace) and silently fall back to the
|
|
597
|
+
# master key on a master-configured client.
|
|
598
|
+
bound_token = opts[:session_token]
|
|
599
|
+
bound_token = bound_token.session_token if bound_token.respond_to?(:session_token)
|
|
600
|
+
bound_token = bound_token.to_s.strip
|
|
601
|
+
@session_token = bound_token.empty? ? nil : bound_token
|
|
496
602
|
|
|
497
603
|
@require_https = opts.fetch(:require_https, ENV["PARSE_REQUIRE_HTTPS"] == "true")
|
|
498
604
|
@allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false)
|
|
@@ -1016,14 +1122,20 @@ module Parse
|
|
|
1016
1122
|
|
|
1017
1123
|
token = opts[:session_token]
|
|
1018
1124
|
# When no explicit token was passed AND the caller didn't ask to send
|
|
1019
|
-
# the master key, fall through to the fiber-local ambient set
|
|
1020
|
-
# `Parse.with_session
|
|
1021
|
-
#
|
|
1022
|
-
# `admin.do_thing(use_master_key: true)`
|
|
1023
|
-
# `with_session(user)` block
|
|
1125
|
+
# the master key, fall through to (in order) the fiber-local ambient set
|
|
1126
|
+
# by `Parse.with_session`, then this client's own bound `@session_token`.
|
|
1127
|
+
# Explicit `use_master_key: true` is treated as a deliberate admin call
|
|
1128
|
+
# and skips both — otherwise an `admin.do_thing(use_master_key: true)`
|
|
1129
|
+
# nested inside a `with_session(user)` block (or on a token-bound client)
|
|
1130
|
+
# would silently downgrade. The ambient wins over the bound token so a
|
|
1131
|
+
# `with_session` override inside a user-scoped client still takes effect.
|
|
1024
1132
|
if token.nil? && !(explicit_master && opts[:use_master_key] == true)
|
|
1025
1133
|
ambient = Parse.current_session_token
|
|
1026
|
-
|
|
1134
|
+
# A whitespace-only ambient must not count as present: otherwise it
|
|
1135
|
+
# blocks the bound-token fallback below and then fails the later
|
|
1136
|
+
# `token.present?` check, silently sending the master key instead.
|
|
1137
|
+
token = ambient if ambient.is_a?(String) && !ambient.strip.empty?
|
|
1138
|
+
token = @session_token if (token.nil? || token.to_s.strip.empty?) && @session_token
|
|
1027
1139
|
end
|
|
1028
1140
|
if token.present?
|
|
1029
1141
|
token = token.session_token if token.respond_to?(:session_token)
|
|
@@ -1302,9 +1414,53 @@ module Parse
|
|
|
1302
1414
|
# Unwrap the `{ "result" => ... }` envelope from a successful cloud-code response.
|
|
1303
1415
|
# Guards against unusual server payloads (non-Hash bodies) by returning the raw
|
|
1304
1416
|
# result rather than raising TypeError on `String#[]`/`Integer#[]`.
|
|
1417
|
+
#
|
|
1418
|
+
# Parse Server 8.0 flipped `encodeParseObjectInCloudFunction` to true and 9.0
|
|
1419
|
+
# removed the opt-out, so a cloud function that returns a Parse object now
|
|
1420
|
+
# yields a `__type`-encoded dictionary (`{"__type":"Object","className":...}`)
|
|
1421
|
+
# where a pre-8.x caller received a plain attribute Hash. We decode those
|
|
1422
|
+
# self-describing envelopes back into `Parse::Object` / `Parse::Pointer` so the
|
|
1423
|
+
# value a caller sees is consistent across server versions and matches what
|
|
1424
|
+
# every other Parse SDK returns. Decoding is conservative: only a fully-shaped
|
|
1425
|
+
# Object/Pointer envelope is converted, and an Object of an UNregistered class
|
|
1426
|
+
# is left as a raw Hash (building it would degrade to a field-less Pointer).
|
|
1427
|
+
# Plain Hashes and arbitrary `__type` app data pass through untouched.
|
|
1305
1428
|
def self._extract_cloud_result(response)
|
|
1306
1429
|
r = response.result
|
|
1307
|
-
r.is_a?(Hash) ? r["result"] : r
|
|
1430
|
+
value = r.is_a?(Hash) ? r["result"] : r
|
|
1431
|
+
_decode_cloud_value(value)
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1434
|
+
# @!visibility private
|
|
1435
|
+
# Recursively decode Parse-encoded values in a cloud-code result. See
|
|
1436
|
+
# {._extract_cloud_result} for the rationale and the conservatism rules.
|
|
1437
|
+
def self._decode_cloud_value(value)
|
|
1438
|
+
case value
|
|
1439
|
+
when Array
|
|
1440
|
+
value.map { |v| _decode_cloud_value(v) }
|
|
1441
|
+
when Hash
|
|
1442
|
+
type = value["__type"] || value[:__type]
|
|
1443
|
+
class_name = value["className"] || value[:className]
|
|
1444
|
+
object_id = value["objectId"] || value[:objectId]
|
|
1445
|
+
if type == Parse::Model::TYPE_POINTER && class_name && object_id
|
|
1446
|
+
# Pointers carry no attributes, so building one is lossless even for
|
|
1447
|
+
# an unregistered class (yields a Parse::Pointer).
|
|
1448
|
+
Parse::Object.build(value)
|
|
1449
|
+
elsif type == Parse::Model::TYPE_OBJECT && class_name && object_id &&
|
|
1450
|
+
Parse::Model.find_class(class_name)
|
|
1451
|
+
# Only build a full object when the class is registered; otherwise
|
|
1452
|
+
# Parse::Object.build collapses to a field-less Pointer and we'd lose
|
|
1453
|
+
# the attributes — better to hand back the raw Hash.
|
|
1454
|
+
Parse::Object.build(value)
|
|
1455
|
+
else
|
|
1456
|
+
# Plain Hash, partial envelope, or non-object `__type` (Date/GeoPoint/
|
|
1457
|
+
# File/Bytes, or literal app data): leave the node shape intact and
|
|
1458
|
+
# only recurse into nested values so embedded objects still decode.
|
|
1459
|
+
value.transform_values { |v| _decode_cloud_value(v) }
|
|
1460
|
+
end
|
|
1461
|
+
else
|
|
1462
|
+
value
|
|
1463
|
+
end
|
|
1308
1464
|
end
|
|
1309
1465
|
|
|
1310
1466
|
# Helper method to trigger cloud jobs and get results.
|
|
@@ -1379,6 +1535,9 @@ module Parse
|
|
|
1379
1535
|
# @option opts [Symbol] :client The client connection to use.
|
|
1380
1536
|
# @option opts [Boolean] :raw Whether to return the raw response object.
|
|
1381
1537
|
# @option opts [Boolean] :master_key Whether to use the master key for this request.
|
|
1538
|
+
# @option opts [Hash, nil] :context An optional caller context forwarded as the
|
|
1539
|
+
# +X-Parse-Cloud-Context+ header. Parse Server maps it to +req.info.context+
|
|
1540
|
+
# in the function handler and flows it through beforeSave/afterSave triggers.
|
|
1382
1541
|
# @return [Object] the result data of the response. nil if there was an error.
|
|
1383
1542
|
def self.call_function(name, body = {}, **opts)
|
|
1384
1543
|
conn = opts[:session] || opts[:client] || :default
|
|
@@ -1388,7 +1547,13 @@ module Parse
|
|
|
1388
1547
|
request_opts[:session_token] = opts[:session_token] if opts[:session_token]
|
|
1389
1548
|
request_opts[:master_key] = opts[:master_key] if opts[:master_key]
|
|
1390
1549
|
|
|
1391
|
-
|
|
1550
|
+
# Build call kwargs; only forward context: when explicitly supplied so
|
|
1551
|
+
# call sites that do not use context produce the same opts hash that
|
|
1552
|
+
# existing mock expectations match against.
|
|
1553
|
+
call_kwargs = { opts: request_opts }
|
|
1554
|
+
call_kwargs[:context] = opts[:context] unless opts[:context].nil?
|
|
1555
|
+
|
|
1556
|
+
response = Parse::Client.client(conn).call_function(name, body, **call_kwargs)
|
|
1392
1557
|
return response if opts[:raw].present?
|
|
1393
1558
|
if response.error?
|
|
1394
1559
|
Parse::Client._safe_warn("CloudCodeError", response, name: name)
|