parse-stack-next 5.7.0 → 5.7.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a554fcec1f40152e03956f033d593c1a4a98fa09ef22e033c270721c701d336
4
- data.tar.gz: 10db25291fc3e2f1e61a36df7499f5b38af2e4e04f73f72f7adfaca8c55dd156
3
+ metadata.gz: b773e88a55f8771a7d898978652e9be673ec496627fcbaa0b0eb68b950486812
4
+ data.tar.gz: 7813d865cb6cc09b6c44070186dbf67383304d9407a5de58fdbb723cce4dc081
5
5
  SHA512:
6
- metadata.gz: aa3333741642e2faea789f829e23c37f3b72dccba2a4c5ed85b3601d2a945a58767cc65cd55ab9eb385225cf6d61cccbe0e4fb824891c273bc735c3c792916e5
7
- data.tar.gz: 572f5a1bd5683d2e9a7aad5293e279401680e268359e3d9f805875453a92ac4ca570ac9c4da9a7c85542b986b91544fcf6adffe223314af0aef7a3828071f7cf
6
+ metadata.gz: 1ffa5266044c369c673c8c5e4e57d4b37d882674eae6d8f7c79f3b1a8318a75dbd76d97036337d8a5416cfdcc022c1db3353e20de53857eaf8e37ac29913dfa7
7
+ data.tar.gz: e897bb63c7030751cbb2a9720e6fde91e23f6c118a6656e9063a84df4ec7305db1e3f8dd141e600f2e9765fb3660fdd26b0c36c2c321184c9743c6be976498c9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,171 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.7.2
4
+
5
+ #### `between` accepts Ruby Range values
6
+
7
+ - **NEW**: The `between` constraint now accepts a Ruby `Range` in addition to
8
+ a 2-element array, so `Person.where(:age.between => 5..25)` and
9
+ `Record.where(:date.between => 5.days.ago...2.days.ago)` work directly. An
10
+ inclusive range (`..`) maps its upper bound to `$lte`, matching the existing
11
+ array form, while an exclusive range (`...`) maps it to `$lt` instead.
12
+ Beginless (`..25`) and endless (`5..`) ranges are also supported and
13
+ constrain only the side that is present, so `Person.where(:age.between =>
14
+ 18..)` compiles to `{"$gte" => 18}` with no upper bound. The array form is
15
+ unchanged, and both forms produce identical output for the same bounds.
16
+
17
+ #### `Query#where_not_between` for the negated form of a range
18
+
19
+ - **NEW**: `Query#where_not_between(field, value)` adds the logical negation
20
+ of `between`: `Person.query.where_not_between(:age, 5..25)` compiles to
21
+ `age < 5 OR age > 25`, accepting the same Range and 2-element Array forms
22
+ as `between` (exclusive ranges flip the upper side to `$gte`, and a
23
+ beginless or endless Range negates to a single one-sided comparison with
24
+ no `$or` needed). It is not available as a `field.not_between => value`
25
+ symbol constraint: a range's negation is inherently an `$or` of two
26
+ comparisons, and only one `$or` group can be safely merged into a
27
+ compiled query, so a symbol constraint that unilaterally emitted one
28
+ could silently collide with an existing `$or` from `or_where`/`|`.
29
+ `where_not_between` instead composes the negation the way
30
+ `Parse::Query.and` already does, so it correctly nests inside a query's
31
+ other `.where` conditions instead of replacing them, and raises
32
+ `ArgumentError` if the query already has an `$or` group rather than
33
+ silently dropping part of it.
34
+
35
+ #### `embed_image` forwards a presigned URL when the source file has one
36
+
37
+ - **FIXED**: `embed_image` always sent the source file's bare `file.url` to
38
+ the embedding provider (or to the SDK's own `:bytes`-mode downloader). On a
39
+ private-bucket file adapter (S3/GCS configured with `presignedUrl: true`),
40
+ `file.url` is the canonical URL with its signature stripped, so the
41
+ provider's fetch (or the SDK's download) got a 403 instead of the image.
42
+ `Parse::File` already captures the signed variant in `file.presigned_url`
43
+ whenever Parse Server returns one, but `embed_image` never read it.
44
+ Recompute now forwards `file.presigned_url` when it is present and not yet
45
+ expired, and falls back to the bare URL otherwise, for both `source: :url`
46
+ and `source: :bytes`. The stored digest is still keyed on the bare
47
+ canonical URL, so a save that only rotates the file's signature does not
48
+ trigger a needless re-embed. The validity check ignores
49
+ `presigned_url_valid?`'s default 60-second safety buffer (meant for a
50
+ browser render, not an immediate server-side fetch), since on a
51
+ private-bucket adapter the fallback URL is not fetchable at all and would
52
+ otherwise 403 for the last minute of every signature's life.
53
+ `Parse::Embeddings::ImageFetch::FetchedImage#url` now stores the
54
+ query-stripped URL rather than the presigned one, since a live signature
55
+ has no reason to survive into that value object's `#inspect` output.
56
+ `source: :url` mode can now forward a presigned URL to the embedding
57
+ provider under the same `Parse::Embeddings.trust_provider_url_fetch`
58
+ consent already required to forward any URL; operators relying on private
59
+ buckets should confirm the provider's egress handling covers
60
+ credential-bearing URLs, not just public ones.
61
+
62
+ #### `Query#get` now resolves aliased `parse_class` names correctly
63
+
64
+ - **FIXED**: `Query#get` looked up the target class with a raw
65
+ `Object.const_get(@table)`, which only worked when the Parse class name
66
+ matched the Ruby constant name exactly. A model that renames its table via
67
+ `parse_class "SomeOtherName"` was never found by this lookup, so `get`
68
+ silently fell back to a generic `Parse::Object`/`Parse::Pointer` instead of
69
+ hydrating the declared model. `Query#get` now passes the table name through
70
+ to `Parse::Object.build` as a string, letting it run its own
71
+ `Parse::Model.find_class` resolution, which already understands
72
+ `parse_class` aliasing.
73
+
74
+ #### `_safe_warn` now writes through a configured logger
75
+
76
+ - **FIXED**: Internal warnings for authentication, timeout, and cloud-code
77
+ errors (`Parse::Client._safe_warn`) always wrote to STDERR, even when an
78
+ app had configured `Parse.logger = Rails.logger` (or any other logger) for
79
+ the rest of its Parse request/response logging. These warnings now route
80
+ through `Parse::Middleware::Logging.logger` when one is configured, so they
81
+ land in the same place as the app's other logs; STDERR remains the fallback
82
+ when no logger is configured, matching prior behavior. Every call site
83
+ raises the corresponding typed `Parse::Error` immediately after this
84
+ warning, so a configured logger that itself raises (a closed handle, a
85
+ full disk, a remote-aggregator client erroring on a socket) now falls back
86
+ to STDERR rather than propagating in place of the real error and masking
87
+ it.
88
+
89
+ ### 5.7.1
90
+
91
+ #### Cache-invalidation webhooks no longer break every application hook for the same trigger
92
+
93
+ - **FIXED**: `Parse::Cache::Invalidation` raised `NoMethodError: undefined
94
+ method 'guard'` on every `_User`, `_Role`, and `_Session` trigger it
95
+ registered, and the failure was not contained. A webhook handler block is
96
+ bound to the payload before it runs, so the bare `guard` / `bump_subject` /
97
+ `subject_id` calls in the handler bodies resolved against
98
+ `Parse::Webhooks::Payload`, which defines none of them. `guard`'s own
99
+ `rescue` never ran, because it lives inside the body that was never entered,
100
+ so the error escaped into the route dispatcher. The dispatcher folds a
101
+ trigger's handlers with `Array#map`, which abandons the collection on the
102
+ first raise, and these triggers install during `Parse.setup` and therefore
103
+ sit ahead of every application handler for the same trigger. One
104
+ unresolvable method name silently prevented an application's own
105
+ `after_save "_User"` hook from running at all, so any work downstream of it
106
+ stopped without a visible cause. The handlers now call the module on an
107
+ explicit receiver captured in a local, which is independent of whatever
108
+ `self` the dispatcher binds. Applications that set
109
+ `cache_invalidation_hooks: false` to work around this can remove it.
110
+ - **FIXED**: The invalidation tests dispatched handlers with a plain
111
+ `Proc#call`, which leaves `self` bound to the module the block closed over,
112
+ so every one of them passed against handlers that could not run in
113
+ production. They now dispatch through the real handler invocation and fail
114
+ against the broken code.
115
+
116
+ #### Whether one failing `after_*` handler stops the rest is now a setting
117
+
118
+ - **NEW**: `Parse::Webhooks.abort_after_callbacks_on_error` decides whether an
119
+ exception raised by one `after_*` handler prevents the remaining handlers for
120
+ that trigger from running. It defaults to `true`, which is the existing
121
+ behavior, so nothing changes on upgrade. Set it to `false` to isolate
122
+ handlers from each other: each one runs regardless of what an earlier one
123
+ raised, and the failure is reported with a warning and a
124
+ `parse.webhooks.handler_error` notification instead of being swallowed
125
+ silently. This was previously not a decision at all but a side effect of
126
+ folding handlers with `Array#map`, which abandons the collection on the
127
+ first raise. Registration order is not fully under an application's control,
128
+ since the SDK's own cache-invalidation triggers install during `Parse.setup`
129
+ and therefore sit ahead of handlers registered by application files loaded
130
+ later, so a single raising handler could silently prevent an application's
131
+ own hooks from running with nothing logged to say they had been skipped.
132
+ - **BEHAVIOR**: The setting governs only whether later handlers still run.
133
+ Neither mode reverts anything: an `after_*` trigger fires once the write has
134
+ already committed, so there is no version of this setting that can undo a
135
+ save. `before_*` dispatch is untouched, and only the accumulating,
136
+ non-rejectable triggers (`after_save`, `after_delete`, `after_logout`) can
137
+ hold more than one handler in the first place. A rejectable `before_*`
138
+ trigger must deny if any handler denies, so its raise must continue to
139
+ abort.
140
+
141
+ #### Failed transactions restore the object they rolled back
142
+
143
+ - **FIXED**: A failed `Parse::Object.transaction` left the in-memory object
144
+ holding its modified values. The rollback snapshotted `Parse::Object#attributes`
145
+ and restored it, but that method returns a schema map of field name to type
146
+ symbol rather than values, so nothing was ever restored. Property values
147
+ live in `@<field>` instance variables; those are now what the rollback
148
+ captures and restores, including values nested inside arrays and hashes,
149
+ relation operation queues, and properties whose ivar did not exist before
150
+ the transaction. State is captured when the object first enters the
151
+ transaction rather than at `batch.add`, so the documented pattern of
152
+ mutating an object and adding it afterwards rolls back correctly.
153
+ - **FIXED**: A failed transaction also left the object with broken change
154
+ tracking. Restoring the schema map defined `@attributes`, which is the
155
+ instance variable `ActiveModel::Dirty` keys its behavior on: once defined it
156
+ builds an `AttributeMutationTracker` over that hash instead of the
157
+ `ForcedMutationTracker` a `Parse::Object` needs, and `clear_changes!` then
158
+ called `forgetting_assignment` on the `[key, value]` pairs `Hash#map`
159
+ yields. Both call sites rescue and warn, so every rollback quietly
160
+ downgraded the object rather than failing. The rollback no longer defines
161
+ `@attributes`.
162
+ - **FIXED**: The mongo-direct role-graph integration test gated on
163
+ `ANALYTICS_DATABASE_URI`, a production variable name the test stack never
164
+ sets, so both of its traversal assertions skipped on every run while the
165
+ per-file reporter still reported the file as passing. It now reads
166
+ `PARSE_TEST_MONGO_URI` like every other mongo-direct integration file and
167
+ still honors `ANALYTICS_DATABASE_URI` as an override.
168
+
3
169
  ### 5.7.0
4
170
 
5
171
  #### Cache keys move into a reserved, app-scoped keyspace
@@ -205,7 +371,7 @@
205
371
  Server's self-access rules, and role-only checks cannot claim a concrete
206
372
  member's pointer or `_User` self permission. CLP cache entries are isolated
207
373
  by Parse application so identically named classes cannot leak policy across
208
- clients. These helpers are advisorythe eventual Parse Server request is
374
+ clients. These helpers are advisory: the eventual Parse Server request is
209
375
  still authoritative.
210
376
 
211
377
  #### Test infrastructure
@@ -191,7 +191,7 @@ module Parse
191
191
 
192
192
  result_hash = dispatch(method, params, agent, id, logger, subscription_manager)
193
193
  { status: result_hash[:status], body: result_hash[:body] }
194
- rescue Parse::Agent::Unauthorized => e
194
+ rescue Parse::Agent::Unauthorized
195
195
  { status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") }
196
196
  rescue StandardError => e
197
197
  # Do not leak the exception class name (gem fingerprinting). Server-
@@ -295,7 +295,7 @@ module Parse
295
295
  else
296
296
  { status: 200, body: jsonrpc_envelope(id, result: result) }
297
297
  end
298
- rescue Parse::Agent::Unauthorized => e
298
+ rescue Parse::Agent::Unauthorized
299
299
  { status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") }
300
300
  rescue Parse::Agent::AccessDenied
301
301
  # Class-authorization denial (agent_hidden / classes: allowlist), e.g.
data/lib/parse/agent.rb CHANGED
@@ -2475,7 +2475,6 @@ module Parse
2475
2475
  end
2476
2476
 
2477
2477
  ActiveSupport::Notifications.instrument("parse.agent.tool_call", payload) do
2478
- response = nil
2479
2478
  # Install a fresh embedding accumulator for this tool span. The
2480
2479
  # process-wide "parse.embeddings.embed" subscriber records each
2481
2480
  # embed into it; the ensure below reads + restores it so the
@@ -52,26 +52,111 @@ module Parse
52
52
  registered
53
53
  end
54
54
 
55
+ # Run a role-trigger invalidation.
56
+ #
57
+ # Public because the registered webhook block calls it on an explicit
58
+ # receiver. See {handle_identity_trigger} for why that matters.
59
+ #
60
+ # @!visibility private
61
+ # @param cache [Parse::Cache::Redis] the keyspace-configured cache.
62
+ # @return [void]
63
+ def handle_role_trigger(cache)
64
+ guard do
65
+ # A role write does not say which users are affected: membership
66
+ # and hierarchy changes arrive as relation deltas on `users` and
67
+ # `roles`, and the cached value is a flattened transitive closure,
68
+ # so a parent-role change reaches the members of every child.
69
+ # Clearing the whole plane is both correct and cheap under a
70
+ # scoped SCAN. Parse Server does the same, for the same reason.
71
+ cache.roles.clear
72
+ # Stamp the epoch so a *foreign* role entry written before this
73
+ # moment is rejected on read. Parse Server does not clear its own
74
+ # role cache on a `_Role` delete, so without this the next read
75
+ # would take its stale entry back and our clear would shorten
76
+ # revocation by nothing.
77
+ cache.roles.touch_epoch
78
+ end
79
+ end
80
+
81
+ # Run an identity-trigger invalidation.
82
+ #
83
+ # Public, and invoked on an explicit receiver, because a webhook
84
+ # handler block does NOT run with `self` bound to the module that
85
+ # created it. {Parse::Webhooks.invoke_handler} binds the block to the
86
+ # payload (`payload.define_singleton_method(name, &block)`), and the
87
+ # historical `payload.instance_exec(payload, &block)` did the same.
88
+ # A block body calling a bare `guard` / `bump_subject` / `subject_id`
89
+ # therefore resolved against {Parse::Webhooks::Payload}, which defines
90
+ # none of them, and raised `NoMethodError` on every `_User`, `_Role`,
91
+ # and `_Session` trigger.
92
+ #
93
+ # That failure was not contained. `guard`'s `rescue` lives inside
94
+ # `guard`'s own body, which was never entered, so the error escaped
95
+ # into `call_route`'s `registry.map`. `Array#map` abandons the whole
96
+ # collection on the first raise, and these triggers register at
97
+ # `Parse.setup` and therefore sit AHEAD of any application handler for
98
+ # the same trigger. One unresolvable method name silently prevented
99
+ # every application `after_save "_User"` hook from running at all.
100
+ #
101
+ # Capturing the module in a local and calling it explicitly is what
102
+ # makes the handler independent of whatever `self` the dispatcher
103
+ # binds.
104
+ #
105
+ # @!visibility private
106
+ # @param cache [Parse::Cache::Redis] the keyspace-configured cache.
107
+ # @param type [Symbol] the trigger being handled.
108
+ # @param payload [Parse::Webhooks::Payload] the incoming payload.
109
+ # @return [void]
110
+ def handle_identity_trigger(cache, type, payload)
111
+ guard do
112
+ case type
113
+ when :after_logout
114
+ # The only trigger Parse Server permits on `_Session`. The
115
+ # object's own sessionToken is scrubbed from the payload, but
116
+ # the token is captured from the requesting user before
117
+ # scrubbing, and for a logout that user *is* the session being
118
+ # ended. A master-key logout carries no user, so fall back to
119
+ # the generation bump.
120
+ #
121
+ # Pass the RAW token, not a pre-hashed digest.
122
+ # `Parse::Cache::SubCache#invalidate` hashes its `key`
123
+ # argument internally for the `:idn` family (see
124
+ # `SubCache#logical_key`), the same way `#get` / `#set` do.
125
+ # That is what makes a `set(raw_token, ...)` /
126
+ # `get(raw_token)` pair round-trip. Hashing here first and
127
+ # handing SubCache an already-hashed value made it hash the
128
+ # digest a second time, landing on a key nothing had ever
129
+ # written to, so logout silently failed to evict the entry.
130
+ token = payload.respond_to?(:session_token) ? payload.session_token : nil
131
+ if token && !token.to_s.empty?
132
+ cache.identity.invalidate(token.to_s)
133
+ else
134
+ bump_subject(cache, subject_id(payload))
135
+ end
136
+ else
137
+ # A `_User` write gives a user id, but identity entries are
138
+ # keyed by session token and no reverse map exists. Bumping a
139
+ # per-user generation invalidates every one of that user's
140
+ # entries in O(1), including tokens this process has never
141
+ # resolved, and without Parse Server's master-key `_Session`
142
+ # query.
143
+ bump_subject(cache, subject_id(payload))
144
+ end
145
+ end
146
+ end
147
+
55
148
  private
56
149
 
57
150
  def install_role_triggers!(cache)
151
+ # `invalidator` is a captured LOCAL, not `self`. The registered block
152
+ # runs with `self` rebound to the payload, so a bare method call in
153
+ # its body would resolve against `Parse::Webhooks::Payload`. A local
154
+ # closes over correctly regardless of the receiver the dispatcher
155
+ # binds.
156
+ invalidator = self
58
157
  TRIGGERS[:role].map do |(type, class_name)|
59
158
  Parse::Webhooks.route(type, class_name) do |payload|
60
- guard do
61
- # A role write does not say which users are affected: membership
62
- # and hierarchy changes arrive as relation deltas on `users` and
63
- # `roles`, and the cached value is a flattened transitive closure,
64
- # so a parent-role change reaches the members of every child.
65
- # Clearing the whole plane is both correct and cheap under a
66
- # scoped SCAN. Parse Server does the same, for the same reason.
67
- cache.roles.clear
68
- # Stamp the epoch so a *foreign* role entry written before this
69
- # moment is rejected on read. Parse Server does not clear its own
70
- # role cache on a `_Role` delete, so without this the next read
71
- # would take its stale entry back and our clear would shorten
72
- # revocation by nothing.
73
- cache.roles.touch_epoch
74
- end
159
+ invalidator.handle_role_trigger(cache)
75
160
  true
76
161
  end
77
162
  [type, class_name]
@@ -79,43 +164,10 @@ module Parse
79
164
  end
80
165
 
81
166
  def install_identity_triggers!(cache)
167
+ invalidator = self
82
168
  TRIGGERS[:identity].map do |(type, class_name)|
83
169
  Parse::Webhooks.route(type, class_name) do |payload|
84
- guard do
85
- case type
86
- when :after_logout
87
- # The only trigger Parse Server permits on `_Session`. The
88
- # object's own sessionToken is scrubbed from the payload, but
89
- # the token is captured from the requesting user before
90
- # scrubbing, and for a logout that user *is* the session being
91
- # ended. A master-key logout carries no user, so fall back to
92
- # the generation bump.
93
- #
94
- # Pass the RAW token, not a pre-hashed digest.
95
- # `Parse::Cache::SubCache#invalidate` hashes its `key`
96
- # argument internally for the `:idn` family (see
97
- # `SubCache#logical_key`), the same way `#get` / `#set` do —
98
- # that is what makes a `set(raw_token, ...)` /
99
- # `get(raw_token)` pair round-trip. Hashing here first and
100
- # handing SubCache an already-hashed value made it hash the
101
- # digest a second time, landing on a key nothing had ever
102
- # written to, so logout silently failed to evict the entry.
103
- token = payload.respond_to?(:session_token) ? payload.session_token : nil
104
- if token && !token.to_s.empty?
105
- cache.identity.invalidate(token.to_s)
106
- else
107
- bump_subject(cache, subject_id(payload))
108
- end
109
- else
110
- # A `_User` write gives a user id, but identity entries are
111
- # keyed by session token and no reverse map exists. Bumping a
112
- # per-user generation invalidates every one of that user's
113
- # entries in O(1), including tokens this process has never
114
- # resolved, and without Parse Server's master-key `_Session`
115
- # query.
116
- bump_subject(cache, subject_id(payload))
117
- end
118
- end
170
+ invalidator.handle_identity_trigger(cache, type, payload)
119
171
  true
120
172
  end
121
173
  [type, class_name]
@@ -17,12 +17,24 @@ module Parse
17
17
  # @!attribute [rw] body
18
18
  # @return [Hash] the body of this request.
19
19
 
20
- # TODO: Document opts and cache options.
21
-
22
20
  # @!attribute [rw] opts
23
- # @return [Hash] a set of options for this request.
21
+ # @return [Hash] per-request options consumed by {Parse::Client#request}
22
+ # when it builds the HTTP headers for this request. Recognized keys:
23
+ # * `:cache` — `false` sends `Cache-Control: no-cache`; `:write_only`
24
+ # skips the cache read but still writes the response; a `Numeric`
25
+ # overrides the cache expiration (seconds) for this request only.
26
+ # * `:use_master_key` — `false` forces the master key off for this
27
+ # request even if the client has one configured.
28
+ # * `:session_token` — a session token to authenticate this request as
29
+ # a specific user, bypassing the client's default auth context.
30
+ # * `:idempotent` — explicitly enables/disables idempotency-header
31
+ # generation for this request, overriding the class-level defaults.
32
+ # * `:request_id` — a caller-supplied idempotency key; see
33
+ # {.enable_idempotency!}.
24
34
  # @!attribute [rw] cache
25
- # @return [Boolean]
35
+ # @return [Boolean] unused by {Parse::Request} itself; retained as a
36
+ # plain accessor for callers that stash a cache handle or flag
37
+ # directly on the request object rather than through `opts[:cache]`.
26
38
  attr_accessor :method, :path, :body, :headers, :opts, :cache
27
39
 
28
40
  # @!visibility private
data/lib/parse/client.rb CHANGED
@@ -505,23 +505,44 @@ module Parse
505
505
  end
506
506
 
507
507
  # @!visibility private
508
- # Emit a redacted warning about a Parse::Response error to stderr.
508
+ # Emit a redacted warning about a Parse::Response error.
509
509
  #
510
510
  # Routes the response error string through
511
511
  # {Parse::Middleware::BodyBuilder.redact} to strip credentials (passwords,
512
512
  # tokens, sessionTokens, access_tokens, authData) before logging, and
513
513
  # truncates to {SAFE_WARN_MAX_ERROR_LENGTH} chars.
514
514
  #
515
+ # Writes through {Parse::Middleware::Logging.logger} when the app has
516
+ # configured one (`Parse.logger = ...`), so these warnings land wherever
517
+ # the rest of the app's Parse request/response logging goes instead of
518
+ # bypassing it. Falls back to plain `warn` (STDERR) when no logger is
519
+ # configured, matching prior behavior. Every call site immediately
520
+ # raises the corresponding typed {Parse::Error} right after calling
521
+ # this method, so a misbehaving app-supplied logger (closed handle,
522
+ # full disk, a remote-aggregator client that raises on socket error)
523
+ # must not be allowed to propagate in its place and mask the real
524
+ # error — falls back to `warn` if the logger itself raises.
525
+ #
515
526
  # @param tag [String] the bracketed prefix (e.g. "AuthenticationError").
516
527
  # @param response [Parse::Response] the response carrying the error.
517
528
  # @param name [String, nil] optional cloud-function or job name for context.
518
529
  # @return [nil]
519
530
  def _safe_warn(tag, response, name: nil)
520
531
  err = Parse::Middleware::BodyBuilder.redact(response.error.to_s)[0, SAFE_WARN_MAX_ERROR_LENGTH]
521
- if name
522
- warn "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})"
532
+ msg = if name
533
+ "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})"
534
+ else
535
+ "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})"
536
+ end
537
+ logger = Parse::Middleware::Logging.logger
538
+ if logger
539
+ begin
540
+ logger.warn(msg)
541
+ rescue StandardError
542
+ warn msg
543
+ end
523
544
  else
524
- warn "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})"
545
+ warn msg
525
546
  end
526
547
  nil
527
548
  end
@@ -164,7 +164,15 @@ module Parse
164
164
 
165
165
  mime = verify!(bytes, url: canonical)
166
166
  bytes = strip_metadata(bytes, mime) if exif_strip
167
- FetchedImage.new(bytes: bytes, mime_type: mime, url: canonical)
167
+ # Store the query-stripped URL, not `canonical` verbatim: when
168
+ # `url` is a presigned URL (a private-bucket file adapter),
169
+ # `canonical` carries a live signature, and FetchedImage#url is
170
+ # purely informational from here on (nothing re-fetches it).
171
+ # Keeping the signature out of the struct preserves the
172
+ # log-safety `#inspect` below was written for — third-party
173
+ # provider adapters and error reporters that capture locals
174
+ # would otherwise leak a valid bearer credential through it.
175
+ FetchedImage.new(bytes: bytes, mime_type: mime, url: Parse::File.strip_query(canonical))
168
176
  end
169
177
 
170
178
  # Verify raw bytes: sniff the magic, check the allowlist, and
@@ -382,6 +382,17 @@ module Parse
382
382
  # `"true"`, or a non-matching String) raises
383
383
  # {ConfirmationRequired}. Reset to `nil` to disable.
384
384
  #
385
+ # For a `:file` source backed by a private-bucket adapter
386
+ # (S3/GCS with server-side presigning), the URL forwarded under
387
+ # this sentinel may be the file's presigned URL rather than its
388
+ # bare canonical one — a time-limited bearer credential for that
389
+ # object, not just a pointer to it (see {Parse::File#presigned_url}
390
+ # and `Parse::Core::EmbedManaged.embed_image`). Reviewing the
391
+ # provider's egress behavior before setting this sentinel should
392
+ # account for that: the provider (and anyone with access to its
393
+ # request logs) gains temporary read access to the object for
394
+ # however long the signature remains valid.
395
+ #
385
396
  # @param value [String, nil] {TRUST_PROVIDER_URL_FETCH_SENTINEL} or nil.
386
397
  # @raise [ConfirmationRequired] on any other value.
387
398
  def trust_provider_url_fetch=(value)
@@ -395,10 +406,12 @@ module Parse
395
406
  "String #{TRUST_PROVIDER_URL_FETCH_SENTINEL.inspect}. Plain `true` and " \
396
407
  "other values are refused — forwarding image URLs to a third-party " \
397
408
  "provider lets that provider issue an HTTP request from its own network " \
398
- "with attacker-controllable host/path. Set the sentinel only after you " \
399
- "have configured Parse::Embeddings.allowed_image_hosts AND reviewed the " \
400
- "provider's documented egress behavior (DNS rebinding window, redirect " \
401
- "policy)."
409
+ "with attacker-controllable host/path, and for a private-bucket file may " \
410
+ "hand it a time-limited presigned URL rather than a bare pointer. Set the " \
411
+ "sentinel only after you have configured " \
412
+ "Parse::Embeddings.allowed_image_hosts AND reviewed the provider's " \
413
+ "documented egress behavior (DNS rebinding window, redirect policy, " \
414
+ "request-log retention)."
402
415
  end
403
416
  CONFIG_MUTEX.synchronize { @trust_provider_url_fetch = value }
404
417
  end
@@ -243,6 +243,10 @@ module Parse
243
243
  instance_variable_set ivar, val
244
244
  end
245
245
 
246
+ # Capture after lazy hydration, before a caller can mutate an
247
+ # object returned by this association getter.
248
+ send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
249
+
246
250
  # Track association source for N+1 detection when returning an unfetched pointer
247
251
  # Uses a registry instead of setting instance variables on the pointer object
248
252
  if val.is_a?(Parse::Pointer) && val.pointer? && Parse.warn_on_n_plus_one
@@ -359,6 +359,9 @@ module Parse
359
359
 
360
360
  # Notifies the delegate that the collection changed.
361
361
  def notify_will_change!
362
+ if @delegate && @delegate.respond_to?(:_capture_transaction_state!, true)
363
+ @delegate.send(:_capture_transaction_state!)
364
+ end
362
365
  collection_will_change!
363
366
  forward "#{@key}_will_change!"
364
367
  end
@@ -502,6 +502,10 @@ module Parse
502
502
  val = instance_variable_get ivar
503
503
  end
504
504
 
505
+ # Capture before this getter materializes or returns a mutable
506
+ # proxy that can be changed in place.
507
+ send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
508
+
505
509
  # if the result is not a collection proxy, then create a new one.
506
510
  unless val.is_a?(Parse::PointerCollectionProxy)
507
511
  results = []