parse-stack-next 5.2.0 → 5.3.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/CHANGELOG.md +240 -0
- data/Gemfile.lock +1 -1
- data/README.md +195 -1
- data/Rakefile +193 -40
- data/docs/client_sdk_guide.md +33 -0
- data/docs/mcp_guide.md +135 -4
- data/lib/parse/client.rb +119 -7
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/user.rb +20 -0
- data/lib/parse/model/core/actions.rb +7 -9
- 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 +70 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/model/object.rb +41 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +156 -1
- data/lib/parse/webhooks/payload.rb +205 -34
- data/lib/parse/webhooks.rb +15 -3
- metadata +2 -1
data/Rakefile
CHANGED
|
@@ -35,6 +35,54 @@ def mcp_credentials_or_abort!
|
|
|
35
35
|
[server_url, app_id, rest_api_key, master_key]
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Resolve the identity for `rake client:console`. Returns a session-token String,
|
|
39
|
+
# or nil to mean "anonymous" (no token, no master). Order: PARSE_SESSION_TOKEN,
|
|
40
|
+
# PARSE_CLIENT_ANONYMOUS=true, PARSE_LOGIN_USER (+PARSE_LOGIN_PASSWORD), else
|
|
41
|
+
# prompt for login / token / anon. The login path uses the configured default
|
|
42
|
+
# client (the master client set up just before this is called).
|
|
43
|
+
def client_console_token!
|
|
44
|
+
token = ENV["PARSE_SESSION_TOKEN"].to_s.strip
|
|
45
|
+
return token unless token.empty?
|
|
46
|
+
return nil if ENV["PARSE_CLIENT_ANONYMOUS"].to_s == "true"
|
|
47
|
+
|
|
48
|
+
user = ENV["PARSE_LOGIN_USER"].to_s.strip
|
|
49
|
+
if user.empty?
|
|
50
|
+
print "Identity? [login/token/anon] (login): "
|
|
51
|
+
case $stdin.gets.to_s.strip.downcase
|
|
52
|
+
when "anon", "anonymous" then return nil
|
|
53
|
+
when "token"
|
|
54
|
+
print "Session token (r:...): "
|
|
55
|
+
t = $stdin.gets.to_s.strip
|
|
56
|
+
return t.empty? ? nil : t
|
|
57
|
+
else
|
|
58
|
+
print "Username (blank for anonymous): "
|
|
59
|
+
user = $stdin.gets.to_s.strip
|
|
60
|
+
return nil if user.empty?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
pwd = ENV["PARSE_LOGIN_PASSWORD"].to_s
|
|
65
|
+
if pwd.empty?
|
|
66
|
+
begin
|
|
67
|
+
require "io/console"
|
|
68
|
+
print "Password for #{user}: "
|
|
69
|
+
pwd = $stdin.noecho(&:gets).to_s
|
|
70
|
+
puts
|
|
71
|
+
rescue LoadError, IOError, SystemCallError
|
|
72
|
+
# io/console missing, or stdin is not a TTY (piped input raises
|
|
73
|
+
# Errno::ENOTTY, a SystemCallError — not an IOError). Fall back to a
|
|
74
|
+
# plain read; warn that it will echo.
|
|
75
|
+
warn "[client:console] WARNING: cannot disable echo; password will be visible."
|
|
76
|
+
print "Password for #{user}: "
|
|
77
|
+
pwd = $stdin.gets.to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
u = Parse::User.login(user, pwd.chomp)
|
|
81
|
+
abort "[client:console] login failed for #{user.inspect}" if u.nil? || u.session_token.to_s.empty?
|
|
82
|
+
puts "Logged in as #{u.username} (#{u.id})."
|
|
83
|
+
u.session_token
|
|
84
|
+
end
|
|
85
|
+
|
|
38
86
|
# Default test task runs all tests with Docker enabled.
|
|
39
87
|
#
|
|
40
88
|
# `*disruptive*` tests are EXCLUDED here: they stop/restart the shared
|
|
@@ -48,55 +96,92 @@ Rake::TestTask.new do |t|
|
|
|
48
96
|
t.verbose = true
|
|
49
97
|
end
|
|
50
98
|
|
|
99
|
+
# Shared runner for the file-per-process test tasks. Each test file runs in its
|
|
100
|
+
# own Ruby process (isolation against the shared Parse Server); output streams
|
|
101
|
+
# live, and — for trackability — a PASS/FAIL + duration line per file is printed
|
|
102
|
+
# to STDOUT *and* appended to a progress log you can `tail -f` from another
|
|
103
|
+
# shell while the run is in flight (works even when the run is backgrounded or
|
|
104
|
+
# piped, where STDOUT would otherwise buffer until completion).
|
|
105
|
+
#
|
|
106
|
+
# Env knobs:
|
|
107
|
+
# TEST_PATTERN=<substr> run only files whose path includes <substr>
|
|
108
|
+
# (e.g. TEST_PATTERN=webhook)
|
|
109
|
+
# CONTINUE_ON_FAILURE=false stop at the first failing file
|
|
110
|
+
# (default: run them all, then list every failure)
|
|
111
|
+
#
|
|
112
|
+
# Exits non-zero if any file failed.
|
|
113
|
+
def run_test_files!(label, files, log:)
|
|
114
|
+
$stdout.sync = true
|
|
115
|
+
require "fileutils"
|
|
116
|
+
if (pattern = ENV["TEST_PATTERN"].to_s).length.positive?
|
|
117
|
+
files = files.select { |f| f.include?(pattern) }
|
|
118
|
+
puts "TEST_PATTERN=#{pattern} -> #{files.length} matching file(s)"
|
|
119
|
+
end
|
|
120
|
+
continue = ENV.fetch("CONTINUE_ON_FAILURE", "true") != "false"
|
|
121
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
122
|
+
total = files.length
|
|
123
|
+
started = Time.now
|
|
124
|
+
results = []
|
|
125
|
+
File.write(log, "#{label}: #{total} files, started #{started}\n")
|
|
126
|
+
puts "\n>> #{label}: #{total} files (progress log: #{log})"
|
|
127
|
+
|
|
128
|
+
files.each_with_index do |file, i|
|
|
129
|
+
n = i + 1
|
|
130
|
+
puts "\n" + "=" * 80
|
|
131
|
+
puts "[#{n}/#{total}] #{file}"
|
|
132
|
+
puts "=" * 80
|
|
133
|
+
t0 = Time.now
|
|
134
|
+
ok = system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}")
|
|
135
|
+
dt = Time.now - t0
|
|
136
|
+
results << [file, ok, dt]
|
|
137
|
+
summary = format("[%d/%d] %-4s %7.1fs %s", n, total, ok ? "PASS" : "FAIL", dt, file)
|
|
138
|
+
puts summary
|
|
139
|
+
File.open(log, "a") { |f| f.puts summary }
|
|
140
|
+
if !ok && !continue
|
|
141
|
+
File.open(log, "a") { |f| f.puts "STOPPED at first failure (CONTINUE_ON_FAILURE=false)" }
|
|
142
|
+
break
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
elapsed = Time.now - started
|
|
147
|
+
passed = results.count { |_, ok, _| ok }
|
|
148
|
+
failed = results.reject { |_, ok, _| ok }
|
|
149
|
+
footer = format("%s: %d/%d passed in %.1fs (%.1f min)",
|
|
150
|
+
label, passed, results.length, elapsed, elapsed / 60.0)
|
|
151
|
+
puts "\n" + "=" * 80
|
|
152
|
+
puts footer
|
|
153
|
+
unless failed.empty?
|
|
154
|
+
puts "Failed (#{failed.length}):"
|
|
155
|
+
failed.each { |f, _, d| puts format(" FAIL %7.1fs %s", d, f) }
|
|
156
|
+
end
|
|
157
|
+
puts "=" * 80
|
|
158
|
+
File.open(log, "a") do |f|
|
|
159
|
+
f.puts footer
|
|
160
|
+
failed.each { |ff, _, d| f.puts format(" FAIL %7.1fs %s", d, ff) }
|
|
161
|
+
end
|
|
162
|
+
exit(1) unless failed.empty?
|
|
163
|
+
end
|
|
164
|
+
|
|
51
165
|
# Integration tests require Docker
|
|
52
166
|
namespace :test do
|
|
53
|
-
desc "Run all integration tests (requires Docker)"
|
|
167
|
+
desc "Run all integration tests (requires Docker). " \
|
|
168
|
+
"Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
|
|
54
169
|
task :integration do
|
|
55
170
|
# Disruptive tests (server stop/restart) are run separately via
|
|
56
171
|
# `test:integration:disruptive` so they never interleave with — and
|
|
57
172
|
# flake — the rest of the integration suite against the shared server.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
puts "Running #{integration_files.length} integration test files..."
|
|
62
|
-
integration_files.each_with_index do |file, index|
|
|
63
|
-
puts "Running integration test #{index + 1}/#{integration_files.length}: #{file}"
|
|
64
|
-
|
|
65
|
-
# 10: docker integration test fails for cloud functions
|
|
66
|
-
skip_till = 0
|
|
67
|
-
if (index + 1) <= skip_till
|
|
68
|
-
puts "Skipping test #{index + 1} as per configuration\n"
|
|
69
|
-
next
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
puts "\n" + "="*80
|
|
73
|
-
puts "Running: #{file}"
|
|
74
|
-
puts "="*80
|
|
75
|
-
system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || exit(1)
|
|
76
|
-
end
|
|
77
|
-
puts "\n✅ All integration tests completed successfully!"
|
|
173
|
+
files = FileList["test/lib/**/*integration_test.rb"]
|
|
174
|
+
.exclude("test/lib/**/*disruptive*")
|
|
175
|
+
run_test_files!("Integration tests", files, log: "tmp/integration-progress.log")
|
|
78
176
|
end
|
|
79
177
|
|
|
80
|
-
desc "Run unit tests only (no Docker required)"
|
|
178
|
+
desc "Run unit tests only (no Docker required). " \
|
|
179
|
+
"Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
|
|
81
180
|
task :unit do
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
puts "Running #{unit_files.length} unit test files (no Docker)..."
|
|
87
|
-
unit_files.each_with_index do |file, index|
|
|
88
|
-
puts "Running unit test #{index + 1}/#{unit_files.length}: #{file}"
|
|
89
|
-
|
|
90
|
-
# 73 is problematic Testing Contains and Nin with Parse Objects with contains and nin
|
|
91
|
-
skip_till = 0
|
|
92
|
-
if (index + 1) <= skip_till
|
|
93
|
-
puts "Skipping test #{index + 1} as per configuration"
|
|
94
|
-
next
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || exit(1)
|
|
98
|
-
end
|
|
99
|
-
puts "\n✅ All unit tests completed successfully!"
|
|
181
|
+
files = FileList["test/lib/**/*_test.rb"]
|
|
182
|
+
.exclude("test/lib/**/*integration_test.rb")
|
|
183
|
+
.exclude("test/lib/**/*disruptive*")
|
|
184
|
+
run_test_files!("Unit tests", files, log: "tmp/unit-progress.log")
|
|
100
185
|
end
|
|
101
186
|
|
|
102
187
|
namespace :integration do
|
|
@@ -602,6 +687,74 @@ namespace :mcp do
|
|
|
602
687
|
end
|
|
603
688
|
end
|
|
604
689
|
|
|
690
|
+
# rake client:console — IRB whose DEFAULT client is a non-master client bound to
|
|
691
|
+
# a user's session, so every model query runs as that user (ACL/CLP enforced).
|
|
692
|
+
# Identity: PARSE_SESSION_TOKEN, or PARSE_LOGIN_USER/PARSE_LOGIN_PASSWORD, else
|
|
693
|
+
# prompt. Connection env matches the other tasks; the master key is used only to
|
|
694
|
+
# log in. Helpers in the REPL: client, whoami, as_master { … }.
|
|
695
|
+
namespace :client do
|
|
696
|
+
desc "Interactive console authenticated as a Parse user (session token or login), not master"
|
|
697
|
+
task :console do
|
|
698
|
+
require "irb"
|
|
699
|
+
begin; require "dotenv/load"; rescue LoadError; end
|
|
700
|
+
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
|
|
701
|
+
require "parse-stack"
|
|
702
|
+
|
|
703
|
+
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
|
|
704
|
+
Parse.setup(server_url: server_url, application_id: app_id, api_key: rest_api_key, master_key: master_key)
|
|
705
|
+
master_client = Parse.client
|
|
706
|
+
token = client_console_token! # String, or nil for anonymous
|
|
707
|
+
|
|
708
|
+
# Models memoize their resolved client at the class level (`@client ||=`),
|
|
709
|
+
# so swapping clients[:default] alone is NOT enough — every swap must also
|
|
710
|
+
# drop those cached ivars or already-touched classes keep the old client.
|
|
711
|
+
reset_client_caches = lambda do
|
|
712
|
+
next unless defined?(Parse::Object)
|
|
713
|
+
[Parse::Object, *Parse::Object.descendants, Parse::Query].each do |k|
|
|
714
|
+
k.remove_instance_variable(:@client) if k.instance_variable_defined?(:@client)
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Make a non-master client (session-bound, or anonymous) the default so
|
|
719
|
+
# every model query runs as the user.
|
|
720
|
+
user_client = token ? master_client.become(token) : master_client.anonymous
|
|
721
|
+
Parse::Client.clients[:default] = user_client
|
|
722
|
+
reset_client_caches.call
|
|
723
|
+
|
|
724
|
+
Object.send(:define_method, :client) { user_client }
|
|
725
|
+
# Redacted: GET /users/me returns a Parse::User carrying the live
|
|
726
|
+
# sessionToken, and Parse::Object has no redacted #inspect, so returning the
|
|
727
|
+
# raw object would print the token in the REPL. Hand back a safe summary.
|
|
728
|
+
Object.send(:define_method, :whoami) do
|
|
729
|
+
next "anonymous (no session)" unless token
|
|
730
|
+
r = user_client.current_user(token)
|
|
731
|
+
next "whoami failed: #{r.error}" unless r.success?
|
|
732
|
+
u = r.result
|
|
733
|
+
{ "username" => u["username"], "objectId" => u["objectId"], "session_token" => "[FILTERED]" }
|
|
734
|
+
rescue StandardError => e
|
|
735
|
+
"whoami failed: #{e.message}"
|
|
736
|
+
end
|
|
737
|
+
# Escalate to the master key for the block, then restore the user client.
|
|
738
|
+
# Both swaps must reset the class client caches (see reset_client_caches),
|
|
739
|
+
# otherwise a class touched inside the block keeps master afterward, or a
|
|
740
|
+
# previously-touched class never escalates. Already-instantiated Parse::Query
|
|
741
|
+
# objects held in REPL locals keep their own client and are not reset.
|
|
742
|
+
Object.send(:define_method, :as_master) do |&blk|
|
|
743
|
+
Parse::Client.clients[:default] = master_client
|
|
744
|
+
reset_client_caches.call
|
|
745
|
+
blk.call
|
|
746
|
+
ensure
|
|
747
|
+
Parse::Client.clients[:default] = user_client
|
|
748
|
+
reset_client_caches.call
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
mode = token ? "a USER" : "ANONYMOUS"
|
|
752
|
+
puts "parse-stack-next client console — as #{mode} (no master key). Helpers: client, whoami, as_master { }."
|
|
753
|
+
ARGV.clear
|
|
754
|
+
IRB.start
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
605
758
|
desc "List undocumented methods"
|
|
606
759
|
task "yard:stats" do
|
|
607
760
|
exec "yard stats --list-undoc"
|
data/docs/client_sdk_guide.md
CHANGED
|
@@ -180,6 +180,39 @@ This duality is intentional. The high-level convenience method matches
|
|
|
180
180
|
what mobile SDKs do; the raw client preserves the response so you can
|
|
181
181
|
log or reroute.
|
|
182
182
|
|
|
183
|
+
#### A user-scoped client straight from login
|
|
184
|
+
|
|
185
|
+
`Parse::User#session_client` turns a logged-in user into a **non-master client
|
|
186
|
+
with that user's token bound**, so you don't thread `session_token:` through
|
|
187
|
+
every call. (`Parse.client.become(token)` builds the same thing from any token.)
|
|
188
|
+
`Parse::User#with_session` runs a block as the user:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
client = Parse::User.login("ada", "p4ssw0rd!").session_client
|
|
192
|
+
Parse::Query.new("Post", client: client).results # runs as Ada
|
|
193
|
+
|
|
194
|
+
# Or a block — every REST-routed op inside is authorized as Ada:
|
|
195
|
+
Parse::User.login("ada", "p4ssw0rd!").with_session do
|
|
196
|
+
Post.query.count
|
|
197
|
+
Post.create(title: "Hello")
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`session_client` returns `nil` if the user has no session token (e.g. it was
|
|
202
|
+
fetched/saved under the master key rather than logged in). The bound token is
|
|
203
|
+
applied as the lowest-priority auth fallback, so an explicit per-call
|
|
204
|
+
`session_token:`, a `Parse.with_session` block, or `use_master_key: true` all
|
|
205
|
+
still take precedence.
|
|
206
|
+
|
|
207
|
+
> **Scope boundary.** `with_session` (and `Parse.with_session`) authorize
|
|
208
|
+
> **REST-routed** operations (`find` / `get` / `count` / `save`) as the user.
|
|
209
|
+
> Mongo-direct queries (`results_direct`, `aggregate`, Atlas search) do **not**
|
|
210
|
+
> pick up the ambient session — scope them explicitly with a per-query
|
|
211
|
+
> `session_token:` or a scoped `Parse::Agent`. A no-master client like this one
|
|
212
|
+
> has no mongo-direct path anyway. To run a query as a user *without* a token —
|
|
213
|
+
> via the master key and SDK-side ACL simulation — use
|
|
214
|
+
> `Parse::Query#scope_to_user(user)`.
|
|
215
|
+
|
|
183
216
|
### 2.3 Validate / refresh a session
|
|
184
217
|
|
|
185
218
|
```ruby
|
data/docs/mcp_guide.md
CHANGED
|
@@ -515,10 +515,14 @@ Parse Server version and its `masterKeyIps` configuration.)
|
|
|
515
515
|
- **Subscriptions do not survive a listening-stream reconnect.** Closing the
|
|
516
516
|
`GET` stream tears down the session's LiveQuery subscriptions; a client that
|
|
517
517
|
reconnects must re-issue its `resources/subscribe` calls.
|
|
518
|
-
- **
|
|
519
|
-
the agent factory and
|
|
520
|
-
|
|
521
|
-
|
|
518
|
+
- **Listening streams are owner-bound (not a bare bearer capability).** The
|
|
519
|
+
stream authenticates via the agent factory *and* the server-issued
|
|
520
|
+
`Mcp-Session-Id` is bound to the principal that established it, so another
|
|
521
|
+
authenticated caller who knows or guesses the id is refused with `403`. The
|
|
522
|
+
`Mcp-Session-Id` is still secret-bearing and should be kept confidential, but
|
|
523
|
+
possession alone is no longer sufficient — see **Listening-stream ownership**
|
|
524
|
+
below for the binding model, its limits, and the `principal_resolver:` knob
|
|
525
|
+
master-key deployments need to make it effective.
|
|
522
526
|
- **Per-session and global caps.** A client that subscribes but never opens (or
|
|
523
527
|
later drops) its listening stream leaves LiveQuery subscriptions running until
|
|
524
528
|
the session is torn down. A per-session ceiling (default 100,
|
|
@@ -538,6 +542,73 @@ Parse Server version and its `masterKeyIps` configuration.)
|
|
|
538
542
|
one-time warning at construction when a streaming or subscription/notification
|
|
539
543
|
surface is enabled without a cap.
|
|
540
544
|
|
|
545
|
+
### Listening-stream ownership
|
|
546
|
+
|
|
547
|
+
The GET listening stream is the single server→client bus shared by resource
|
|
548
|
+
subscriptions, [server-initiated notifications](#server-initiated-notifications-general-purpose),
|
|
549
|
+
and [approval elicitation](#approval-workflows-mcp-elicitation). Whoever holds
|
|
550
|
+
that stream receives everything pushed to its `Mcp-Session-Id` — another
|
|
551
|
+
session's `notifications/resources/updated`, `elicitation/create` approval
|
|
552
|
+
prompts, and arbitrary `notify` payloads. So the stream is **owner-bound**: a
|
|
553
|
+
session is tied to the principal that established it, and only the same
|
|
554
|
+
principal may later open (or re-open) its stream.
|
|
555
|
+
|
|
556
|
+
How the binding is established and checked:
|
|
557
|
+
|
|
558
|
+
- **Initialize-bound.** A session created through an `initialize` POST is bound
|
|
559
|
+
authoritatively to that caller's principal. A later `GET` carrying the same
|
|
560
|
+
`Mcp-Session-Id` from a *different* principal is refused with HTTP `403`
|
|
561
|
+
(`-32600`, "Mcp-Session-Id is owned by another principal"). A re-`initialize`
|
|
562
|
+
by the same caller refreshes the binding.
|
|
563
|
+
- **Trust-on-first-use (TOFU) for the decoupled bus.** A session id that
|
|
564
|
+
`initialize` never saw — the `notifications: true` bus, where application code
|
|
565
|
+
pushes to ids it chose itself — is claimed by the first principal to attach a
|
|
566
|
+
listener; a different principal attaching afterward is refused. TOFU closes
|
|
567
|
+
the prior model's eviction-after-claim hole (a second caller could overwrite
|
|
568
|
+
or shadow an existing listener), but a first-mover attacker can still claim an
|
|
569
|
+
*unused* id, so **notification-bus session ids must be high-entropy**.
|
|
570
|
+
- **Stream close keeps the claim.** The binding is dropped only on an explicit
|
|
571
|
+
`DELETE` termination, not on mere stream close — a reconnecting owner keeps
|
|
572
|
+
its claim, and an attacker cannot grab the id during a brief disconnect.
|
|
573
|
+
|
|
574
|
+
The principal fingerprint is derived, in order, from: an operator-supplied
|
|
575
|
+
`principal_resolver:`, then the agent's `session_token` (hashed), then
|
|
576
|
+
`acl_user`, then `acl_role`. With none of these the agent falls back to a shared
|
|
577
|
+
`"mk"` (master-key) principal:
|
|
578
|
+
|
|
579
|
+
- **A master-key-everywhere factory makes owner-binding a no-op.** If every
|
|
580
|
+
request builds a bare master-key agent (no `session_token:` / `acl_user:` /
|
|
581
|
+
`acl_role:`), all agents share the `"mk"` fingerprint and are
|
|
582
|
+
indistinguishable, so the `403` never fires among them. Deployments that
|
|
583
|
+
authenticate users upstream and run master-key agents should supply a
|
|
584
|
+
`principal_resolver:` to restore a real per-user identity:
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
app = Parse::Agent::MCPRackApp.new(
|
|
588
|
+
streaming: true,
|
|
589
|
+
notifications: true, # or resource_subscriptions: true
|
|
590
|
+
principal_resolver: ->(agent, env) {
|
|
591
|
+
# Return a stable per-user id (String). nil/empty falls through to the
|
|
592
|
+
# agent's own scope, then to the shared "mk" principal.
|
|
593
|
+
env["myapp.authenticated_user_id"]
|
|
594
|
+
},
|
|
595
|
+
agent_factory: ->(env) { ... },
|
|
596
|
+
)
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
The resolver must respond to `#call`; an invalid one raises `ArgumentError` at
|
|
600
|
+
construction. Per-user impersonation (binding a real `session_token` per
|
|
601
|
+
request) achieves the same effect without a resolver.
|
|
602
|
+
|
|
603
|
+
**Limits (same scope as the cancellation registry):** the owner registry is
|
|
604
|
+
per-`MCPRackApp` instance and **single-process** — it does not span Puma workers
|
|
605
|
+
or survive a restart. In a clustered deployment the `initialize` POST and the
|
|
606
|
+
`GET` stream may land on different workers, so the initialize-binding degrades
|
|
607
|
+
to TOFU there. The registry is LRU-bounded (default 10,000 sessions) so a stream
|
|
608
|
+
of `initialize`-without-`DELETE` sessions cannot grow it without limit; evicting
|
|
609
|
+
an active owner just downgrades that id to TOFU on its next attach. Blank
|
|
610
|
+
session ids or blank fingerprints fail closed.
|
|
611
|
+
|
|
541
612
|
---
|
|
542
613
|
|
|
543
614
|
## Approval Workflows (MCP elicitation)
|
|
@@ -3035,6 +3106,66 @@ Four different refusal reasons each produce a distinct `:error_code` and message
|
|
|
3035
3106
|
|
|
3036
3107
|
**Resolution order at dispatch:** operator filter ▷ mutation gate ▷ mode ceiling ▷ in-tool class gate. Operator-filter precedence is deliberate — when a tool is excluded by both the operator's `tools: { except: [...] }` AND the mutation gate (or the mode ceiling), the operator-filter message wins so the operator looks at the right knob first. The mode-ceiling message names the tool, not the class — even when the request would have hit an `agent_hidden` class, the ceiling fires first for a refused tool, so the LLM does not learn anything about the class. For tools that pass the ceiling (e.g. `query_class`) the in-tool `assert_class_accessible!` runs next and the `agent_hidden` message echoes the class name supplied by the caller.
|
|
3037
3108
|
|
|
3109
|
+
### Client mode from a webhook — run a handler as the calling user (v5.3.0)
|
|
3110
|
+
|
|
3111
|
+
Parse Server includes the caller's live session token (`user.sessionToken`) in
|
|
3112
|
+
every trigger webhook fired by a logged-in user (it is absent for a master-key
|
|
3113
|
+
request). `Parse::Webhooks::Payload` captures that token before scrubbing it out
|
|
3114
|
+
of `payload.user` / `payload.object` (so it never lands in `payload.as_json` or
|
|
3115
|
+
the request log) and exposes two opt-in, user-scoped handles — the webhook
|
|
3116
|
+
counterpart of constructing a client-mode agent by hand:
|
|
3117
|
+
|
|
3118
|
+
```ruby
|
|
3119
|
+
Parse::Webhooks.route(:after_save, "Post") do
|
|
3120
|
+
# self is the Parse::Webhooks::Payload.
|
|
3121
|
+
next true unless session_token? # master-key save → no caller token
|
|
3122
|
+
|
|
3123
|
+
# A client-mode Parse::Agent bound to the caller — ACL/CLP enforced, no
|
|
3124
|
+
# master-key fallback. Same posture as Parse::Agent.new(session_token:, client: <no master_key>).
|
|
3125
|
+
visible = user_agent.execute(:query_class, class_name: "Post", limit: 20)
|
|
3126
|
+
|
|
3127
|
+
# …or a raw user-scoped Parse::Client (token is BOUND, so plain REST calls
|
|
3128
|
+
# are authorized as the user with no per-call session_token: needed):
|
|
3129
|
+
mine = user_client.request(:get, "classes/Post").result
|
|
3130
|
+
true
|
|
3131
|
+
end
|
|
3132
|
+
```
|
|
3133
|
+
|
|
3134
|
+
| Payload handle | Returns | `nil` when |
|
|
3135
|
+
|----------------|---------|-----------|
|
|
3136
|
+
| `payload.session_token` | the caller's raw token (`String`) | master-key request (no user) |
|
|
3137
|
+
| `payload.user_agent(**opts)` | non-master `Parse::Agent` in **client mode**, token bound | no token |
|
|
3138
|
+
| `payload.user_client` | non-master `Parse::Client` with the token **bound** | no token |
|
|
3139
|
+
|
|
3140
|
+
`user_client` binds the token via the new `Parse::Client.new(session_token:)`
|
|
3141
|
+
option, applied as the lowest-priority auth fallback on every request — an
|
|
3142
|
+
explicit per-call `session_token:`, a `Parse.with_session` block, or an explicit
|
|
3143
|
+
`use_master_key: true` all still take precedence. Everything the Client Mode
|
|
3144
|
+
ceiling above says about a hand-built client-mode agent applies verbatim to
|
|
3145
|
+
`payload.user_agent`: read tools only unless `allow_mutations: true`, and
|
|
3146
|
+
`acl_user:` / `acl_role:` are not available on a no-master client.
|
|
3147
|
+
|
|
3148
|
+
The same user-scoped client is available client-side from a login
|
|
3149
|
+
(`Parse::User#session_client`, or `Parse.client.become(token)` from any token),
|
|
3150
|
+
and `Parse::User#with_session` / `Parse::Client#with_session` run a block as the
|
|
3151
|
+
user so ordinary model operations are implicitly scoped:
|
|
3152
|
+
|
|
3153
|
+
```ruby
|
|
3154
|
+
client = Parse::User.login(username, password).session_client # non-master, token bound
|
|
3155
|
+
Parse::Query.new("Post", client: client).results # query as the user
|
|
3156
|
+
Parse::User.login(username, password).with_session { Post.query.count }
|
|
3157
|
+
```
|
|
3158
|
+
|
|
3159
|
+
`with_session` (and `Parse.with_session`) authorize **REST-routed** operations
|
|
3160
|
+
(`find` / `get` / `count` / `save`) as the user. Mongo-direct queries
|
|
3161
|
+
(`results_direct`, `aggregate`, Atlas search) do NOT pick up the ambient
|
|
3162
|
+
session — they resolve auth from the query's own `session_token:` / `acl_user:`
|
|
3163
|
+
and otherwise run in **master** mode (a full master read, not anonymous), so
|
|
3164
|
+
scope them explicitly with a per-query `session_token:` or a scoped
|
|
3165
|
+
`Parse::Agent`. This is deliberate: mongo-direct scoping is always explicit in
|
|
3166
|
+
this SDK, so the ambient fiber state can never silently flip a mongo-direct
|
|
3167
|
+
query into user scope (or be mistaken for it).
|
|
3168
|
+
|
|
3038
3169
|
---
|
|
3039
3170
|
|
|
3040
3171
|
## `agent_hidden` — Per-Class Agent-Surface Denial
|
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)
|