Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,91 @@
## parse-stack-next Changelog

### 5.7.2

#### `between` accepts Ruby Range values

- **NEW**: The `between` constraint now accepts a Ruby `Range` in addition to
a 2-element array, so `Person.where(:age.between => 5..25)` and
`Record.where(:date.between => 5.days.ago...2.days.ago)` work directly. An
inclusive range (`..`) maps its upper bound to `$lte`, matching the existing
array form, while an exclusive range (`...`) maps it to `$lt` instead.
Beginless (`..25`) and endless (`5..`) ranges are also supported and
constrain only the side that is present, so `Person.where(:age.between =>
18..)` compiles to `{"$gte" => 18}` with no upper bound. The array form is
unchanged, and both forms produce identical output for the same bounds.

#### `Query#where_not_between` for the negated form of a range

- **NEW**: `Query#where_not_between(field, value)` adds the logical negation
of `between`: `Person.query.where_not_between(:age, 5..25)` compiles to
`age < 5 OR age > 25`, accepting the same Range and 2-element Array forms
as `between` (exclusive ranges flip the upper side to `$gte`, and a
beginless or endless Range negates to a single one-sided comparison with
no `$or` needed). It is not available as a `field.not_between => value`
symbol constraint: a range's negation is inherently an `$or` of two
comparisons, and only one `$or` group can be safely merged into a
compiled query, so a symbol constraint that unilaterally emitted one
could silently collide with an existing `$or` from `or_where`/`|`.
`where_not_between` instead composes the negation the way
`Parse::Query.and` already does, so it correctly nests inside a query's
other `.where` conditions instead of replacing them, and raises
`ArgumentError` if the query already has an `$or` group rather than
silently dropping part of it.

#### `embed_image` forwards a presigned URL when the source file has one

- **FIXED**: `embed_image` always sent the source file's bare `file.url` to
the embedding provider (or to the SDK's own `:bytes`-mode downloader). On a
private-bucket file adapter (S3/GCS configured with `presignedUrl: true`),
`file.url` is the canonical URL with its signature stripped, so the
provider's fetch (or the SDK's download) got a 403 instead of the image.
`Parse::File` already captures the signed variant in `file.presigned_url`
whenever Parse Server returns one, but `embed_image` never read it.
Recompute now forwards `file.presigned_url` when it is present and not yet
expired, and falls back to the bare URL otherwise, for both `source: :url`
and `source: :bytes`. The stored digest is still keyed on the bare
canonical URL, so a save that only rotates the file's signature does not
trigger a needless re-embed. The validity check ignores
`presigned_url_valid?`'s default 60-second safety buffer (meant for a
browser render, not an immediate server-side fetch), since on a
private-bucket adapter the fallback URL is not fetchable at all and would
otherwise 403 for the last minute of every signature's life.
`Parse::Embeddings::ImageFetch::FetchedImage#url` now stores the
query-stripped URL rather than the presigned one, since a live signature
has no reason to survive into that value object's `#inspect` output.
`source: :url` mode can now forward a presigned URL to the embedding
provider under the same `Parse::Embeddings.trust_provider_url_fetch`
consent already required to forward any URL; operators relying on private
buckets should confirm the provider's egress handling covers
credential-bearing URLs, not just public ones.

#### `Query#get` now resolves aliased `parse_class` names correctly

- **FIXED**: `Query#get` looked up the target class with a raw
`Object.const_get(@table)`, which only worked when the Parse class name
matched the Ruby constant name exactly. A model that renames its table via
`parse_class "SomeOtherName"` was never found by this lookup, so `get`
silently fell back to a generic `Parse::Object`/`Parse::Pointer` instead of
hydrating the declared model. `Query#get` now passes the table name through
to `Parse::Object.build` as a string, letting it run its own
`Parse::Model.find_class` resolution, which already understands
`parse_class` aliasing.

#### `_safe_warn` now writes through a configured logger

- **FIXED**: Internal warnings for authentication, timeout, and cloud-code
errors (`Parse::Client._safe_warn`) always wrote to STDERR, even when an
app had configured `Parse.logger = Rails.logger` (or any other logger) for
the rest of its Parse request/response logging. These warnings now route
through `Parse::Middleware::Logging.logger` when one is configured, so they
land in the same place as the app's other logs; STDERR remains the fallback
when no logger is configured, matching prior behavior. Every call site
raises the corresponding typed `Parse::Error` immediately after this
warning, so a configured logger that itself raises (a closed handle, a
full disk, a remote-aggregator client erroring on a socket) now falls back
to STDERR rather than propagating in place of the real error and masking
it.

### 5.7.1

#### Cache-invalidation webhooks no longer break every application hook for the same trigger
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
parse-stack-next (5.7.1)
parse-stack-next (5.7.2)
activemodel (>= 6.1, < 9)
activesupport (>= 6.1, < 9)
connection_pool (>= 2.2, < 4)
Expand Down
1 change: 0 additions & 1 deletion lib/parse/agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2475,7 +2475,6 @@ def execute(tool_name, **kwargs)
end

ActiveSupport::Notifications.instrument("parse.agent.tool_call", payload) do
response = nil
# Install a fresh embedding accumulator for this tool span. The
# process-wide "parse.embeddings.embed" subscriber records each
# embed into it; the ensure below reads + restores it so the
Expand Down
4 changes: 2 additions & 2 deletions lib/parse/agent/mcp_dispatcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_t

result_hash = dispatch(method, params, agent, id, logger, subscription_manager)
{ status: result_hash[:status], body: result_hash[:body] }
rescue Parse::Agent::Unauthorized => e
rescue Parse::Agent::Unauthorized
{ status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") }
rescue StandardError => e
# Do not leak the exception class name (gem fingerprinting). Server-
Expand Down Expand Up @@ -295,7 +295,7 @@ def self.dispatch(method, params, agent, id, logger = nil, subscription_manager
else
{ status: 200, body: jsonrpc_envelope(id, result: result) }
end
rescue Parse::Agent::Unauthorized => e
rescue Parse::Agent::Unauthorized
{ status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") }
rescue Parse::Agent::AccessDenied
# Class-authorization denial (agent_hidden / classes: allowlist), e.g.
Expand Down
29 changes: 25 additions & 4 deletions lib/parse/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -505,23 +505,44 @@ def setup(opts = {}, &block)
end

# @!visibility private
# Emit a redacted warning about a Parse::Response error to stderr.
# Emit a redacted warning about a Parse::Response error.
#
# Routes the response error string through
# {Parse::Middleware::BodyBuilder.redact} to strip credentials (passwords,
# tokens, sessionTokens, access_tokens, authData) before logging, and
# truncates to {SAFE_WARN_MAX_ERROR_LENGTH} chars.
#
# Writes through {Parse::Middleware::Logging.logger} when the app has
# configured one (`Parse.logger = ...`), so these warnings land wherever
# the rest of the app's Parse request/response logging goes instead of
# bypassing it. Falls back to plain `warn` (STDERR) when no logger is
# configured, matching prior behavior. Every call site immediately
# raises the corresponding typed {Parse::Error} right after calling
# this method, so a misbehaving app-supplied logger (closed handle,
# full disk, a remote-aggregator client that raises on socket error)
# must not be allowed to propagate in its place and mask the real
# error — falls back to `warn` if the logger itself raises.
#
# @param tag [String] the bracketed prefix (e.g. "AuthenticationError").
# @param response [Parse::Response] the response carrying the error.
# @param name [String, nil] optional cloud-function or job name for context.
# @return [nil]
def _safe_warn(tag, response, name: nil)
err = Parse::Middleware::BodyBuilder.redact(response.error.to_s)[0, SAFE_WARN_MAX_ERROR_LENGTH]
if name
warn "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})"
msg = if name
"[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})"
else
"[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})"
end
logger = Parse::Middleware::Logging.logger
if logger
begin
logger.warn(msg)
rescue StandardError
warn msg
end
else
warn "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})"
warn msg
end
nil
end
Expand Down
20 changes: 16 additions & 4 deletions lib/parse/client/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,24 @@ class Request
# @!attribute [rw] body
# @return [Hash] the body of this request.

# TODO: Document opts and cache options.

# @!attribute [rw] opts
# @return [Hash] a set of options for this request.
# @return [Hash] per-request options consumed by {Parse::Client#request}
# when it builds the HTTP headers for this request. Recognized keys:
# * `:cache` — `false` sends `Cache-Control: no-cache`; `:write_only`
# skips the cache read but still writes the response; a `Numeric`
# overrides the cache expiration (seconds) for this request only.
# * `:use_master_key` — `false` forces the master key off for this
# request even if the client has one configured.
# * `:session_token` — a session token to authenticate this request as
# a specific user, bypassing the client's default auth context.
# * `:idempotent` — explicitly enables/disables idempotency-header
# generation for this request, overriding the class-level defaults.
# * `:request_id` — a caller-supplied idempotency key; see
# {.enable_idempotency!}.
# @!attribute [rw] cache
# @return [Boolean]
# @return [Boolean] unused by {Parse::Request} itself; retained as a
# plain accessor for callers that stash a cache handle or flag
# directly on the request object rather than through `opts[:cache]`.
attr_accessor :method, :path, :body, :headers, :opts, :cache

# @!visibility private
Expand Down
21 changes: 17 additions & 4 deletions lib/parse/embeddings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,17 @@ def max_media_bytes
# `"true"`, or a non-matching String) raises
# {ConfirmationRequired}. Reset to `nil` to disable.
#
# For a `:file` source backed by a private-bucket adapter
# (S3/GCS with server-side presigning), the URL forwarded under
# this sentinel may be the file's presigned URL rather than its
# bare canonical one — a time-limited bearer credential for that
# object, not just a pointer to it (see {Parse::File#presigned_url}
# and `Parse::Core::EmbedManaged.embed_image`). Reviewing the
# provider's egress behavior before setting this sentinel should
# account for that: the provider (and anyone with access to its
# request logs) gains temporary read access to the object for
# however long the signature remains valid.
#
# @param value [String, nil] {TRUST_PROVIDER_URL_FETCH_SENTINEL} or nil.
# @raise [ConfirmationRequired] on any other value.
def trust_provider_url_fetch=(value)
Expand All @@ -395,10 +406,12 @@ def trust_provider_url_fetch=(value)
"String #{TRUST_PROVIDER_URL_FETCH_SENTINEL.inspect}. Plain `true` and " \
"other values are refused — forwarding image URLs to a third-party " \
"provider lets that provider issue an HTTP request from its own network " \
"with attacker-controllable host/path. Set the sentinel only after you " \
"have configured Parse::Embeddings.allowed_image_hosts AND reviewed the " \
"provider's documented egress behavior (DNS rebinding window, redirect " \
"policy)."
"with attacker-controllable host/path, and for a private-bucket file may " \
"hand it a time-limited presigned URL rather than a bare pointer. Set the " \
"sentinel only after you have configured " \
"Parse::Embeddings.allowed_image_hosts AND reviewed the provider's " \
"documented egress behavior (DNS rebinding window, redirect policy, " \
"request-log retention)."
end
CONFIG_MUTEX.synchronize { @trust_provider_url_fetch = value }
end
Expand Down
10 changes: 9 additions & 1 deletion lib/parse/embeddings/image_fetch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,15 @@ def fetch!(url, allow_insecure: false, exif_strip: true, max_bytes: nil)

mime = verify!(bytes, url: canonical)
bytes = strip_metadata(bytes, mime) if exif_strip
FetchedImage.new(bytes: bytes, mime_type: mime, url: canonical)
# Store the query-stripped URL, not `canonical` verbatim: when
# `url` is a presigned URL (a private-bucket file adapter),
# `canonical` carries a live signature, and FetchedImage#url is
# purely informational from here on (nothing re-fetches it).
# Keeping the signature out of the struct preserves the
# log-safety `#inspect` below was written for — third-party
# provider adapters and error reporters that capture locals
# would otherwise leak a valid bearer credential through it.
FetchedImage.new(bytes: bytes, mime_type: mime, url: Parse::File.strip_query(canonical))
end

# Verify raw bytes: sniff the magic, check the allowlist, and
Expand Down
45 changes: 41 additions & 4 deletions lib/parse/model/core/embed_managed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ def self.recompute_embedding!(record, directive)
return if stored_digest == digest && target_present

provider = Parse::Embeddings.provider(directive.provider_name)
vectors = call_provider(provider, directive, input)
vectors = call_provider(provider, directive, input, record)
unless vectors.is_a?(Array) && vectors.length == 1 && vectors.first.is_a?(Array)
raise Parse::Embeddings::InvalidResponseError,
"Parse::Core::EmbedManaged (#{record.class}##{directive.into}): provider " \
Expand Down Expand Up @@ -774,16 +774,27 @@ def self.build_source_input(record, directive)
# provider a {Parse::Embeddings::ImageFetch::FetchedImage}; `:url`
# mode forwards the raw URL String (the provider validates and
# fetches it itself).
def self.call_provider(provider, directive, input)
#
# `input` is the bare canonical URL used for the digest (see
# {.build_source_input}). It stays stable across saves, so an
# unsigned re-read of the same file location does not force a
# re-embed.
# The actual fetch/forward target prefers the file's presigned
# URL ({Parse::File#presigned_url}) when one is currently valid,
# since a private-bucket adapter's bare `file.url` is stripped of
# its signature and will not resolve for the provider or for the
# SDK's own `:bytes`-mode download.
def self.call_provider(provider, directive, input, record)
if directive.image?
fetch_url = presigned_fetch_url(record, directive, input)
source = if directive.bytes_mode?
Parse::Embeddings::ImageFetch.fetch!(
input,
fetch_url,
allow_insecure: directive.allow_insecure ? true : false,
exif_strip: directive.exif_strip != false,
)
else
input
fetch_url
end
provider.embed_image([source],
input_type: directive.input_type,
Expand All @@ -793,6 +804,32 @@ def self.call_provider(provider, directive, input)
end
end

# @!visibility private
# Resolve the URL to actually fetch/forward for an image
# directive: the source file's currently-valid presigned URL if
# it has one, otherwise the bare canonical `fallback` (the same
# string used for the digest). Never used for text directives, so
# `directive.sources.first` is always a `:file` property here.
#
# Checks validity with a zero safety buffer rather than
# {Parse::File#presigned_url_valid?}'s default 60-second one. That
# default exists so a browser has time to render before a
# presigned URL goes stale; here it would instead spend the last
# 60 seconds of a perfectly usable presigned URL falling back to
# `fallback`, which on a private-bucket adapter is not fetchable
# at all. A fetch that starts immediately after this check has no
# meaningful use for that margin, and a 403 from a URL that
# expired mid-request is strictly better than a guaranteed 403
# from a URL known unfetchable in advance.
def self.presigned_fetch_url(record, directive, fallback)
file = record.public_send(directive.sources.first)
if file.respond_to?(:presigned_url_valid?) && file.presigned_url_valid?(buffer: 0)
file.presigned_url
else
fallback
end
end

# @!visibility private
# Concatenate source-field string values. `nil` and blank entries
# are skipped; remaining values are joined with a double newline.
Expand Down
4 changes: 0 additions & 4 deletions lib/parse/model/core/querying.rb
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,6 @@ def each(constraints = {}, &block)
# same created_at date (down to the microsecond). This prevents getting the same
# record in the next query request.
exclusion_set = results.select { |r| r.created_at == next_cursor.created_at }.map(&:id)
results = nil
cursor = next_cursor
end
end
Expand Down Expand Up @@ -356,7 +355,6 @@ def first_as(token, constraints = {})
# Object.latest(:user.eq => user, limit: 5) # => 5 most recent for user
# @return [Parse::Object] the most recently created object matching constraints.
def latest(constraints = {})
fetch_count = 1
if constraints.is_a?(Numeric)
fetch_count = constraints.to_i
constraints = {}
Expand Down Expand Up @@ -385,7 +383,6 @@ def latest(constraints = {})
# Object.last_updated(:user.eq => user, limit: 3) # => 3 most recently updated for user
# @return [Parse::Object] the most recently updated object matching constraints.
def last_updated(constraints = {})
fetch_count = 1
if constraints.is_a?(Numeric)
fetch_count = constraints.to_i
constraints = {}
Expand Down Expand Up @@ -600,7 +597,6 @@ def find(*parse_ids, type: :parallel, compact: true, cache: nil, session_token:
parse_ids.compact!
# determines if the result back to the call site is an array or a single result
as_array = parse_ids.count > 1
results = []

# Default to write-only cache mode - find always gets fresh data
# but updates cache for future cached reads. Controlled by feature flag.
Expand Down
1 change: 0 additions & 1 deletion lib/parse/model/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1867,7 +1867,6 @@ def self.build(json, table = nil, fetched_keys: nil, nested_fetched_keys: nil)
# we should do a reverse lookup on who is registered for a different class type
# than their name with parse_class
klass = Parse::Model.find_class className
o = nil
if klass.present?
# when creating objects from Parse JSON data, don't use dirty tracking since
# we are considering these objects as "pristine"
Expand Down
Loading