parse-stack-next 5.8.2 → 5.8.4

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +323 -0
  3. data/README.md +20 -6
  4. data/bin/parse-console +9 -3
  5. data/docs/TEST_SERVER.md +1 -1
  6. data/docs/caching.md +7 -2
  7. data/docs/mcp_guide.md +34 -0
  8. data/lib/parse/agent/constraint_translator.rb +66 -2
  9. data/lib/parse/agent/mcp_rack_app.rb +90 -1
  10. data/lib/parse/agent/mcp_server.rb +24 -2
  11. data/lib/parse/agent/mcp_subscriptions.rb +32 -1
  12. data/lib/parse/agent/tools.rb +3 -3
  13. data/lib/parse/agent.rb +14 -3
  14. data/lib/parse/atlas_search/protected_paths.rb +163 -0
  15. data/lib/parse/atlas_search.rb +62 -0
  16. data/lib/parse/client.rb +148 -6
  17. data/lib/parse/clp_scope.rb +53 -2
  18. data/lib/parse/embeddings/cache.rb +29 -9
  19. data/lib/parse/graphql/type_generator.rb +184 -5
  20. data/lib/parse/live_query/client.rb +27 -9
  21. data/lib/parse/live_query/event.rb +55 -1
  22. data/lib/parse/live_query/event_queue.rb +6 -1
  23. data/lib/parse/live_query/health_monitor.rb +1 -1
  24. data/lib/parse/model/file.rb +37 -3
  25. data/lib/parse/mongodb.rb +19 -0
  26. data/lib/parse/pipeline_security.rb +242 -5
  27. data/lib/parse/query/constraints.rb +101 -8
  28. data/lib/parse/query/cursor.rb +161 -13
  29. data/lib/parse/query.rb +42 -0
  30. data/lib/parse/retrieval/agent_tool.rb +82 -0
  31. data/lib/parse/retrieval/retriever.rb +14 -2
  32. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  33. data/lib/parse/stack/rack_advisory.rb +53 -0
  34. data/lib/parse/stack/version.rb +1 -1
  35. data/lib/parse/stack.rb +106 -2
  36. data/lib/parse/two_factor_auth/user_extension.rb +41 -5
  37. data/lib/parse/two_factor_auth.rb +81 -4
  38. data/lib/parse/vector_search/hybrid.rb +21 -0
  39. data/lib/parse/vector_search.rb +15 -0
  40. data/lib/parse/webhooks/replay_protection.rb +5 -1
  41. data/lib/parse/webhooks.rb +23 -4
  42. data/parse-stack-next.gemspec +12 -5
  43. metadata +14 -7
@@ -576,22 +576,115 @@ module Parse
576
576
  end
577
577
  private :literal_char
578
578
 
579
+ # Shorthand classes the membership check understands, as ASCII sets.
580
+ # Characters outside ASCII never reach this check: {#literal_char}
581
+ # only yields ASCII punctuation, letters, and digits.
582
+ SHORTHAND_CLASSES = {
583
+ "d" => ->(c) { c.match?(/\A[0-9]\z/) },
584
+ "w" => ->(c) { c.match?(/\A[A-Za-z0-9_]\z/) },
585
+ "s" => ->(c) { c.match?(/\A[ \t\r\n\f\v]\z/) },
586
+ }.freeze
587
+
579
588
  # @!visibility private
580
589
  # Whether an atom (ignoring its quantifier) can match `ch` in either
581
590
  # case. Anything that cannot be checked counts as a match.
591
+ #
592
+ # The pattern text is never compiled with Ruby's Regexp: the caller's
593
+ # pattern is meant for MongoDB's PCRE engine, and compiling attacker
594
+ # text with Onigmo exposes the regexp-compiler memory-safety bugs
595
+ # fixed in Ruby 3.3.13, 3.4.12, and 4.0.9 (GitLab CVE-2026-89078 and
596
+ # related). Membership is decided from the parsed atom instead: a
597
+ # literal, a `\<punct>` escape, `\d \w \s` and their negations, or
598
+ # a character class built from those and ranges.
582
599
  def atom_matches?(item, ch)
583
600
  atom = item[:atom]
584
- return true unless atom == [:char] || atom == [:dot]
585
- re = begin
586
- Regexp.new(atom == [:dot] ? "." : item[:text].to_s)
587
- rescue RegexpError, ArgumentError
588
- nil
589
- end
590
- return true if re.nil?
591
- [ch, ch.swapcase, ch.downcase(:fold), ch.upcase].uniq.any? { |c| re.match?(c) }
601
+ return true if atom == [:dot]
602
+ return true unless atom == [:char]
603
+ variants = [ch, ch.swapcase, ch.downcase(:fold), ch.upcase].uniq
604
+ text = item[:text].to_s
605
+ if text.start_with?("[")
606
+ class_matches?(text, variants)
607
+ else
608
+ single = single_matcher(text)
609
+ single.nil? || variants.any? { |c| single.call(c) }
610
+ end
592
611
  end
593
612
  private :atom_matches?
594
613
 
614
+ # @!visibility private
615
+ # A predicate for one non-class atom's text, or nil when it cannot be
616
+ # checked (the caller then counts it as a match).
617
+ def single_matcher(text)
618
+ if text.length == 1 && !"\\.[]()|?*+{}^$".include?(text)
619
+ ->(c) { c == text }
620
+ elsif text.length == 2 && text.start_with?("\\")
621
+ code = text[1]
622
+ if SHORTHAND_CLASSES.key?(code)
623
+ SHORTHAND_CLASSES[code]
624
+ elsif SHORTHAND_CLASSES.key?(code.downcase)
625
+ ->(c) { !SHORTHAND_CLASSES[code.downcase].call(c) }
626
+ elsif !code.match?(/[A-Za-z0-9]/)
627
+ ->(c) { c == code }
628
+ end
629
+ end
630
+ end
631
+ private :single_matcher
632
+
633
+ # @!visibility private
634
+ # Whether a `[...]` class can match any of `variants`. Members are
635
+ # literals, `\<punct>` escapes, `\d \w \s` (and negations), and
636
+ # `a-z` ranges; any other construct (POSIX classes, `\p{...}`, nested
637
+ # sets) makes the class count as a match.
638
+ def class_matches?(text, variants)
639
+ body = text[1...-1].to_s
640
+ negated = body.start_with?("^")
641
+ body = body[1..] if negated
642
+ members = []
643
+ i = 0
644
+ while i < body.length
645
+ c = body[i]
646
+ if c == "\\"
647
+ code = body[i + 1]
648
+ return true if code.nil?
649
+ if SHORTHAND_CLASSES.key?(code.downcase)
650
+ members << single_matcher("\\#{code}")
651
+ i += 2
652
+ next
653
+ end
654
+ return true if code.match?(/[A-Za-z0-9]/)
655
+ lo = code
656
+ i += 2
657
+ elsif c == "["
658
+ return true
659
+ else
660
+ lo = c
661
+ i += 1
662
+ end
663
+ if body[i] == "-" && i + 1 < body.length
664
+ hi = body[i + 1]
665
+ return true if hi == "\\" || hi == "["
666
+ # Members are plain data, not closures: a lambda built inside
667
+ # this loop would close over the loop's variables and see only
668
+ # their last values.
669
+ members << [:range, lo, hi]
670
+ i += 2
671
+ else
672
+ members << [:literal, lo]
673
+ end
674
+ end
675
+ member = lambda do |v|
676
+ members.any? do |m|
677
+ case m
678
+ when Proc then m.call(v)
679
+ when Array
680
+ m[0] == :range ? (v >= m[1] && v <= m[2]) : v == m[1]
681
+ end
682
+ end
683
+ end
684
+ negated ? !variants.all?(&member) : variants.any?(&member)
685
+ end
686
+ private :class_matches?
687
+
595
688
  # @!visibility private
596
689
  # A repeated group body that cannot split its input more than one way
597
690
  # per repeat, so repeating it is safe:
@@ -197,10 +197,53 @@ module Parse
197
197
  }
198
198
  end
199
199
 
200
+ # Maximum JSON nesting accepted when parsing a serialized cursor.
201
+ MAX_JSON_NESTING = 20
202
+
203
+ # Envelope version for signed cursor tokens.
204
+ SIGNED_VERSION = 2
205
+
206
+ class << self
207
+ # Secret used to HMAC-sign serialized cursors. When set, {#serialize}
208
+ # emits a signed token and {.deserialize} refuses any token without a
209
+ # valid signature. A per-call `secret:` argument overrides it. The
210
+ # secret must be a String of at least {MIN_SECRET_BYTES} bytes;
211
+ # anything else raises ArgumentError, since a blank or short key is
212
+ # guessable and would make signed tokens forgeable.
213
+ #
214
+ # The length check cannot tell a random key from a long guessable one.
215
+ # Generate the secret from a cryptographic random source, store it with
216
+ # your other application secrets, and keep it out of source control:
217
+ #
218
+ # ruby -rsecurerandom -e 'puts SecureRandom.hex(32)' # 32 random bytes, 64 hex characters
219
+ #
220
+ # Parse::Cursor.signing_secret = ENV.fetch("PARSE_CURSOR_SECRET")
221
+ #
222
+ # Rotating the secret invalidates every token signed with the old one.
223
+ # @return [String, nil]
224
+ attr_reader :signing_secret
225
+
226
+ # @param value [String, nil]
227
+ def signing_secret=(value)
228
+ @signing_secret = value.nil? ? nil : validate_secret!(value)
229
+ end
230
+ end
231
+
232
+ # Shortest signing secret accepted, in bytes (the HMAC-SHA256 block
233
+ # recommendation for a random key is at least 32 bytes).
234
+ MIN_SECRET_BYTES = 32
235
+
200
236
  # Serialize the cursor state to a JSON string for persistence.
201
237
  # Useful for background jobs that may be interrupted and resumed.
202
238
  #
239
+ # The token contains the class name and the full query constraints.
240
+ # Without a secret it is plain JSON that anyone holding it can edit. When
241
+ # a secret is configured (via {.signing_secret} or `secret:`), the state
242
+ # is wrapped in an envelope with an HMAC-SHA256 signature that
243
+ # {.deserialize} verifies before rebuilding the query.
244
+ #
203
245
  # @example Save cursor state for later
246
+ # Parse::Cursor.signing_secret = ENV["CURSOR_SECRET"]
204
247
  # cursor = Song.cursor(limit: 100)
205
248
  # cursor.next_page
206
249
  # state = cursor.serialize
@@ -208,11 +251,12 @@ module Parse
208
251
  #
209
252
  # @example Resume in a background job
210
253
  # state = redis.get("cursor:#{job_id}")
211
- # cursor = Parse::Cursor.deserialize(state)
254
+ # cursor = Parse::Cursor.deserialize(state, expected_class: Song)
212
255
  # cursor.each_page { |page| process(page) }
213
256
  #
257
+ # @param secret [String, nil] signing secret; defaults to {.signing_secret}
214
258
  # @return [String] JSON string containing cursor state
215
- def serialize
259
+ def serialize(secret: self.class.signing_secret)
216
260
  require "json"
217
261
  state = {
218
262
  class_name: @query.table,
@@ -228,27 +272,78 @@ module Parse
228
272
  order_direction: @order_direction,
229
273
  version: 1, # For future compatibility
230
274
  }
231
- JSON.generate(state)
275
+ payload = JSON.generate(state)
276
+ return payload if secret.nil?
277
+
278
+ JSON.generate(v: SIGNED_VERSION, state: payload, sig: self.class.sign(payload, secret))
232
279
  end
233
280
 
234
281
  # Alias for serialize
282
+ # @param secret [String, nil] signing secret; defaults to {.signing_secret}
235
283
  # @return [String] JSON string containing cursor state
236
- def to_json
237
- serialize
284
+ def to_json(*_args, secret: self.class.signing_secret)
285
+ serialize(secret: secret)
238
286
  end
239
287
 
240
288
  # Deserialize a cursor from a previously serialized state.
241
289
  #
290
+ # The state names the class to query and the constraints to apply, so a
291
+ # token from an untrusted source (a client, a URL parameter) can query any
292
+ # class with any constraints unless it is signed. Configure a secret and
293
+ # pass `expected_class:` before accepting a token from outside the
294
+ # process.
295
+ #
296
+ # - With a secret (from `secret:` or {.signing_secret}), the token must be
297
+ # a signed envelope whose HMAC matches; the comparison is constant-time.
298
+ # Unsigned or tampered tokens raise ArgumentError.
299
+ # - Without a secret, an unsigned token is accepted only as trusted
300
+ # persistence: state the process wrote itself and stored where clients
301
+ # cannot change it (a job queue, a server-side table). Pass
302
+ # `trusted: true` to say so. Without it, an unsigned token is still
303
+ # accepted in this release with a one-time warning; a future minor
304
+ # release will require `trusted: true`. Never use this mode on
305
+ # client-supplied tokens.
306
+ #
242
307
  # @param json_string [String] the serialized cursor state
308
+ # @param secret [String, nil] signing secret; defaults to {.signing_secret}
309
+ # @param expected_class [Class, String, nil] refuse tokens for any other
310
+ # Parse class
311
+ # @param trusted [Boolean] the caller asserts an unsigned token came from
312
+ # trusted persistence; ignored when a secret is in effect
243
313
  # @return [Parse::Cursor] a cursor restored to the saved state
244
- # @raise [ArgumentError] if the JSON is invalid or missing required fields
314
+ # @raise [ArgumentError] if the JSON is invalid, missing required fields,
315
+ # fails signature verification, or names a different class
245
316
  #
246
317
  # @example Resume a cursor
247
- # cursor = Parse::Cursor.deserialize(saved_state)
318
+ # cursor = Parse::Cursor.deserialize(saved_state, expected_class: Song)
248
319
  # cursor.each_page { |page| process(page) }
249
- def self.deserialize(json_string)
320
+ def self.deserialize(json_string, secret: signing_secret, expected_class: nil, trusted: false)
250
321
  require "json"
251
- state = JSON.parse(json_string, symbolize_names: true)
322
+ require "active_support/security_utils"
323
+ raise ArgumentError, "Invalid cursor state: expected a String" unless json_string.is_a?(String)
324
+ secret = validate_secret!(secret) unless secret.nil?
325
+
326
+ state = parse_state(json_string)
327
+ signed = state.is_a?(Hash) && state.key?(:sig) && state.key?(:state)
328
+
329
+ if signed
330
+ if secret.nil?
331
+ raise ArgumentError, "Signed cursor state requires a signing secret to verify"
332
+ end
333
+ payload = state[:state]
334
+ sig = state[:sig]
335
+ unless payload.is_a?(String) && sig.is_a?(String) &&
336
+ ActiveSupport::SecurityUtils.secure_compare(sign(payload, secret), sig)
337
+ raise ArgumentError, "Invalid cursor state: signature mismatch"
338
+ end
339
+ state = parse_state(payload)
340
+ elsif secret
341
+ raise ArgumentError, "Invalid cursor state: unsigned cursor refused while a signing secret is configured"
342
+ else
343
+ warn_unsigned_once unless trusted
344
+ end
345
+
346
+ raise ArgumentError, "Invalid cursor state: expected a JSON object" unless state.is_a?(Hash)
252
347
 
253
348
  # Validate required fields
254
349
  required = [:class_name, :page_size, :order_field, :order_direction]
@@ -257,6 +352,13 @@ module Parse
257
352
  raise ArgumentError, "Invalid cursor state: missing #{missing.join(", ")}"
258
353
  end
259
354
 
355
+ if expected_class
356
+ expected_name = expected_class.respond_to?(:parse_class) ? expected_class.parse_class : expected_class.to_s
357
+ unless state[:class_name].to_s == expected_name
358
+ raise ArgumentError, "Cursor state is for class #{state[:class_name].to_s.inspect}, expected #{expected_name.inspect}"
359
+ end
360
+ end
361
+
260
362
  # Get the model class
261
363
  klass = Parse::Model.find_class(state[:class_name])
262
364
  unless klass
@@ -286,9 +388,53 @@ module Parse
286
388
 
287
389
  # Alias for deserialize
288
390
  # @param json_string [String] the serialized cursor state
391
+ # @param kwargs [Hash] `secret:`, `expected_class:`, and `trusted:`, as for {.deserialize}
289
392
  # @return [Parse::Cursor] a cursor restored to the saved state
290
- def self.from_json(json_string)
291
- deserialize(json_string)
393
+ def self.from_json(json_string, **kwargs)
394
+ deserialize(json_string, **kwargs)
395
+ end
396
+
397
+ # HMAC-SHA256 of a serialized cursor payload, hex encoded.
398
+ # @!visibility private
399
+ def self.sign(payload, secret)
400
+ require "openssl"
401
+ OpenSSL::HMAC.hexdigest("SHA256", validate_secret!(secret), payload)
402
+ end
403
+
404
+ # The secret, when it is a String of at least {MIN_SECRET_BYTES} bytes
405
+ # that is not only whitespace.
406
+ # @!visibility private
407
+ # @raise [ArgumentError]
408
+ def self.validate_secret!(secret)
409
+ unless secret.is_a?(String) && !secret.strip.empty? && secret.bytesize >= MIN_SECRET_BYTES
410
+ raise ArgumentError,
411
+ "Parse::Cursor signing secret must be a String of at least #{MIN_SECRET_BYTES} " \
412
+ "bytes (got #{secret.class}#{secret.is_a?(String) ? ", #{secret.bytesize} bytes" : ""})."
413
+ end
414
+ secret
415
+ end
416
+
417
+ # @!visibility private
418
+ def self.parse_state(json)
419
+ JSON.parse(json, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
420
+ rescue JSON::ParserError => e
421
+ raise ArgumentError, "Invalid cursor state: #{e.message}"
422
+ end
423
+
424
+ # @!visibility private
425
+ def self.warn_unsigned_once
426
+ return if @warned_unsigned
427
+ @warned_unsigned = true
428
+ warn "[Parse::Cursor] Deserializing an unsigned cursor. Unsigned cursor state can " \
429
+ "name any class and constraints; never accept it from a client. Set " \
430
+ "Parse::Cursor.signing_secret to sign and verify cursors, or pass trusted: true " \
431
+ "for state the process stored itself. A future minor release will require one of them."
432
+ end
433
+
434
+ # Reset the one-time unsigned warning. Test-only.
435
+ # @!visibility private
436
+ def self.reset_unsigned_warning!
437
+ @warned_unsigned = false
292
438
  end
293
439
 
294
440
  private
@@ -307,8 +453,10 @@ module Parse
307
453
 
308
454
  # Deserialize a value from JSON storage
309
455
  def self.deserialize_value(value)
310
- return value unless value.is_a?(Hash) && value["__type"] == "Date"
311
- DateTime.parse(value["iso"])
456
+ return value unless value.is_a?(Hash)
457
+ type = value["__type"] || value[:__type]
458
+ return value unless type == "Date"
459
+ DateTime.parse(value["iso"] || value[:iso])
312
460
  end
313
461
 
314
462
  # Set up the ordering for cursor pagination.
data/lib/parse/query.rb CHANGED
@@ -4463,6 +4463,18 @@ module Parse
4463
4463
  # every document silently satisfied the constraint.
4464
4464
  return field_str if field_str.start_with?("_")
4465
4465
 
4466
+ # A name the model declares, either its Ruby property name or its
4467
+ # declared server name (`belongs_to :owner_ref, field: "OwnerRef"`
4468
+ # takes both `owner_ref` and `OwnerRef`), resolves to the declared
4469
+ # column whether or not a query's field alias scope is active. Left
4470
+ # to the formatter, an unscoped `OwnerRef` columnizes to `ownerRef`,
4471
+ # which no property declares, so it passed through as `OwnerRef`
4472
+ # instead of `_p_OwnerRef`, and a `$ne` / `$nin` / `$exists: false`
4473
+ # on that missing column matched every row. Undeclared names (the
4474
+ # pipeline-local aliases described below) are not affected.
4475
+ declared = declared_column_for_direct_mongodb(field_str)
4476
+ return declared if declared
4477
+
4466
4478
  # Apply field formatting for regular fields
4467
4479
  formatted = Query.format_field(field)
4468
4480
 
@@ -4525,6 +4537,36 @@ module Parse
4525
4537
  end
4526
4538
  end
4527
4539
 
4540
+ # The storage column for a name the query's model declares, by its Ruby
4541
+ # property name or its declared server name, or nil for any other name.
4542
+ # A pointer resolves to `_p_<server name>`. Built-in system fields and
4543
+ # server names that start with `_` are left to the default handling.
4544
+ #
4545
+ # @param field_str [String]
4546
+ # @return [String, nil]
4547
+ # @api private
4548
+ def declared_column_for_direct_mongodb(field_str)
4549
+ return nil if field_str.empty? || field_str.include?(".")
4550
+ klass = table_model_class
4551
+ return nil unless klass.respond_to?(:field_map) && klass.respond_to?(:fields)
4552
+ fmap = klass.field_map
4553
+ sym = field_str.to_sym
4554
+ local = if fmap.key?(sym)
4555
+ sym
4556
+ else
4557
+ fmap.find { |_k, remote| remote.to_s == field_str }&.first
4558
+ end
4559
+ return nil if local.nil?
4560
+ return nil if Query::FIELD_ALIAS_BUILTINS.include?(local.to_s)
4561
+ remote = fmap[local].to_s
4562
+ return nil if remote.empty? || remote.start_with?("_")
4563
+ return nil if %w[objectId createdAt updatedAt ACL].include?(remote)
4564
+ type = klass.fields[local] || klass.fields[remote.to_sym]
4565
+ type == :pointer ? "_p_#{remote}" : remote
4566
+ rescue NameError
4567
+ nil
4568
+ end
4569
+
4528
4570
  # Convert a value for direct MongoDB execution.
4529
4571
  # @param field [String] the field name (for context)
4530
4572
  # @param value [Object] the value to convert
@@ -123,6 +123,10 @@ module Parse
123
123
 
124
124
  klass = Parse::Agent::MetadataRegistry.resolve_searchable!(class_name)
125
125
  cname = klass.parse_class
126
+ # The same class gate every other read tool runs: the per-agent
127
+ # `classes:` allowlist and the CLP `find` check for scoped agents.
128
+ # resolve_searchable! only consults the global hidden flag.
129
+ Parse::Agent::Tools.assert_class_accessible!(cname, agent: agent, op: :find)
126
130
 
127
131
  unless query.is_a?(String) && !query.strip.empty?
128
132
  raise Parse::Agent::ValidationError, "semantic_search: `query` must be a non-empty String."
@@ -155,6 +159,10 @@ module Parse
155
159
  end
156
160
  assert_filter_fields_allowed!(filter, allowed)
157
161
  assert_filter_fields_allowed!(vector_filter, allowed)
162
+ # The operator's per-agent filter (`filters: { Class => {...} }`) is
163
+ # ANDed into the post-search match after the caller's filter has been
164
+ # checked, so it is unconditional, as on every other read tool.
165
+ filter = compose_per_agent_filter(agent, klass, cname, filter)
158
166
 
159
167
  # Tenant scope (nil for unscoped classes / bypassed admins; raises
160
168
  # AccessDenied for an un-bound agent on a scoped class).
@@ -271,6 +279,80 @@ module Parse
271
279
  envelope
272
280
  end
273
281
 
282
+ # @!visibility private
283
+ # The caller's post-search filter with the agent's per-agent filter for
284
+ # `cname` ANDed in. The per-agent filter is converted to storage form
285
+ # against the class's declared fields (see {.per_agent_storage_filter}).
286
+ # The caller's filter gets the pointer conversion
287
+ # Parse::Retrieval.retrieve would have applied, since retrieve converts
288
+ # only top-level keys and both sides end up nested under `$and`.
289
+ def compose_per_agent_filter(agent, klass, cname, filter)
290
+ per_agent = agent.respond_to?(:filter_for) ? agent.filter_for(cname) : nil
291
+ return filter if per_agent.nil? || per_agent.empty?
292
+ scoped = per_agent_storage_filter(klass, per_agent)
293
+ return scoped if filter.nil? || filter.empty?
294
+ caller_filter = Parse::Retrieval.send(:translate_pointer_filter_values, klass, filter)
295
+ { "$and" => [scoped, caller_filter] }
296
+ end
297
+
298
+ # Built-in columns a per-agent filter may name, with their stored names.
299
+ PER_AGENT_BUILTIN_COLUMNS = {
300
+ "objectId" => "_id", "createdAt" => "_created_at", "updatedAt" => "_updated_at",
301
+ }.freeze
302
+
303
+ # @!visibility private
304
+ # A REST-form per-agent filter rewritten for a post-search `$match`.
305
+ # Each field is resolved through the class's declared properties, by
306
+ # its Ruby name or its declared server name (`PublicState` stays
307
+ # `PublicState`), so it addresses the stored column exactly; pointer
308
+ # fields use their `_p_` column and storage-form values, and Date and
309
+ # pointer values are converted. A field the class does not declare is
310
+ # refused: guessing its column could aim a negative operator such as
311
+ # `$ne` at a column that does not exist, which matches every row.
312
+ def per_agent_storage_filter(klass, node)
313
+ case node
314
+ when Array
315
+ node.map { |child| per_agent_storage_filter(klass, child) }
316
+ when Hash
317
+ node.each_with_object({}) do |(key, value), out|
318
+ k = key.to_s
319
+ if %w[$and $or $nor].include?(k)
320
+ # A single Hash is one clause; Array(hash) would split it into
321
+ # key/value pairs.
322
+ clauses = value.is_a?(Hash) ? [value] : Array(value)
323
+ out[k] = per_agent_storage_filter(klass, clauses)
324
+ elsif k.start_with?("$")
325
+ raise Parse::Agent::ValidationError,
326
+ "semantic_search: per-agent filter operator #{k} is not supported on this tool."
327
+ else
328
+ column, pointer = per_agent_column(klass, k)
329
+ converted = klass.query.send(:convert_value_for_direct_mongodb, k, value)
330
+ out[pointer ? "_p_#{column}" : column] = converted
331
+ end
332
+ end
333
+ else
334
+ node
335
+ end
336
+ end
337
+
338
+ # @!visibility private
339
+ # [stored column, pointer?] for a per-agent filter field.
340
+ def per_agent_column(klass, name)
341
+ return [PER_AGENT_BUILTIN_COLUMNS[name], false] if PER_AGENT_BUILTIN_COLUMNS.key?(name)
342
+ fmap = klass.respond_to?(:field_map) ? klass.field_map : {}
343
+ local = if klass.fields.key?(name.to_sym)
344
+ name.to_sym
345
+ else
346
+ fmap.find { |_l, remote| remote.to_s == name }&.first
347
+ end
348
+ if local.nil?
349
+ raise Parse::Agent::ValidationError,
350
+ "semantic_search: per-agent filter field #{name.inspect} is not a declared " \
351
+ "property of #{klass.parse_class}."
352
+ end
353
+ [(fmap[local] || local).to_s, klass.fields[local] == :pointer]
354
+ end
355
+
274
356
  # @!visibility private
275
357
  # A profile's hybrid settings with the lexical branch restricted to the
276
358
  # text sources the agent may read. Without this the lexical search runs
@@ -264,9 +264,12 @@ module Parse
264
264
  # contract. `merge_filters` is last-wins, so ordering the tenant
265
265
  # constraint LAST guarantees its key survives any caller collision:
266
266
  # callers can narrow the result set but never escape their tenant.
267
- lexical[:filter] = merge_filters(filter, lexical[:filter], tenant_filter_hash(klass, tenant_scope))
267
+ # `filter` may hold an operator-imposed constraint (the agent tool's
268
+ # per-agent filter), so a hybrid config's own lexical filter is ANDed
269
+ # with it rather than merged over it.
270
+ lexical[:filter] = merge_filters(and_filters(filter, lexical[:filter]), tenant_filter_hash(klass, tenant_scope))
268
271
  vector[:field] ||= field unless field.nil?
269
- vector[:filter] = merge_filters(vector[:filter], filter)
272
+ vector[:filter] = and_filters(vector[:filter], filter)
270
273
  vector[:vector_filter] = merge_filters(vector[:vector_filter], merged_vector_filter)
271
274
 
272
275
  klass.hybrid_search(
@@ -391,6 +394,15 @@ module Parse
391
394
  { wire_name(klass, field) => value }
392
395
  end
393
396
 
397
+ # @!visibility private
398
+ # AND non-empty filter hashes together. Returns nil when nothing is
399
+ # left to apply, and a single filter unwrapped.
400
+ def and_filters(*filters)
401
+ present = filters.reject { |f| f.nil? || (f.respond_to?(:empty?) && f.empty?) }
402
+ return nil if present.empty?
403
+ present.length == 1 ? present.first : { "$and" => present }
404
+ end
405
+
394
406
  # @!visibility private
395
407
  # Shallow-merge non-empty filter hashes (left-to-right; later keys
396
408
  # win). Returns nil when nothing is left to apply.
@@ -8,5 +8,5 @@ Parse.setup app_id: ENV["PARSE_SERVER_APPLICATION_ID"],
8
8
  server_url: "https://localhost:1337/parse"
9
9
  # optional
10
10
  # logging: false,
11
- # cache: Moneta.new(:File, dir: 'tmp/cache'),
11
+ # cache: Parse::Cache::Redis.new(url: ENV['REDIS_URL']),
12
12
  # expires: 1 # cache ttl 1 second
@@ -0,0 +1,53 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Stack
6
+ # Load-time check for Rack releases affected by the 2026-04 Rack
7
+ # advisories. The fixes are per branch (2.2.23, 3.1.21, 3.2.6, with no
8
+ # fixed 3.0 release). The gemspec floor covers the 2.2 line; RubyGems
9
+ # limits a requirement string to 255 characters, which is too short to
10
+ # exclude each vulnerable 3.x release by name, so a vulnerable 3.x
11
+ # release is reported here instead of being refused at install time.
12
+ module RackAdvisory
13
+ # Lowest fixed release per Rack 3.x minor line; nil means no fix exists
14
+ # on that line.
15
+ FIXED_3X = { "3.0" => nil, "3.1" => "3.1.21", "3.2" => "3.2.6" }.freeze
16
+
17
+ module_function
18
+
19
+ # Whether a Rack version is on a 3.x line with a known-vulnerable
20
+ # release below its fix.
21
+ # @param version [String, Gem::Version]
22
+ # @return [Boolean]
23
+ def vulnerable?(version)
24
+ v = Gem::Version.new(version.to_s)
25
+ return false unless v.segments.first == 3
26
+ line = v.segments.first(2).join(".")
27
+ return false unless FIXED_3X.key?(line)
28
+ fixed = FIXED_3X[line]
29
+ fixed.nil? || v < Gem::Version.new(fixed)
30
+ end
31
+
32
+ # Warn once when the loaded Rack release is vulnerable.
33
+ # @param version [String, Gem::Version, nil] defaults to the activated
34
+ # rack gem's version.
35
+ # @return [Boolean] true when a warning was printed
36
+ def check!(version = nil)
37
+ version ||= Gem.loaded_specs["rack"]&.version
38
+ return false if version.nil? || @warned
39
+ return false unless vulnerable?(version)
40
+ @warned = true
41
+ warn "[parse-stack-next] Rack #{version} is affected by the 2026-04 Rack " \
42
+ "security advisories. Upgrade to Rack 3.2.6 or later (or 3.1.21 or later " \
43
+ "on the 3.1 line; the 3.0 line has no fixed release)."
44
+ true
45
+ end
46
+
47
+ # @!visibility private
48
+ def reset!
49
+ @warned = false
50
+ end
51
+ end
52
+ end
53
+ end
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.8.2"
9
+ VERSION = "5.8.4"
10
10
  end
11
11
  end