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.
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
- integration_files = FileList["test/lib/**/*integration_test.rb"]
59
- .exclude("test/lib/**/*disruptive*")
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
- unit_files = FileList["test/lib/**/*_test.rb"]
83
- .exclude("test/lib/**/*integration_test.rb")
84
- .exclude("test/lib/**/*disruptive*")
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"
@@ -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
- - **Session id is a bearer capability.** The listening stream authenticates via
519
- the agent factory and keys delivery off the server-issued `Mcp-Session-Id`,
520
- which the client must keep secret possession of a valid session id (plus a
521
- valid agent) is sufficient to attach. This matches the cancellation model.
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
- @master_key = opts[:master_key] || ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
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 by
1020
- # `Parse.with_session`. Explicit `use_master_key: true` is treated as
1021
- # a deliberate admin call and skips the ambient otherwise an
1022
- # `admin.do_thing(use_master_key: true)` nested inside a
1023
- # `with_session(user)` block would silently downgrade.
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
- token = ambient if ambient.is_a?(String) && !ambient.empty?
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)