parse-stack-next 5.8.0 → 5.8.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca6cbc923754826337ad1abf67ad900d15ac483cef4d03536862923491eaf64c
4
- data.tar.gz: 3c5c10805ec33190f755e8a745c4b6cbac2fd2bd458f1032b57e125e86ed6e73
3
+ metadata.gz: d49d6371c1e6873ea48401f789d9087e23870530ae405bf2629ea9db67360995
4
+ data.tar.gz: 9589dfb1ec150a78d527c6ad0097a3ee4263701b104baf7b54afb73e73ec3bdf
5
5
  SHA512:
6
- metadata.gz: a34e92d57d8cfbd3ba87bbe21baad8995a7dd1f3aba09a980ab3ed364ab961e5f8934a6bd73f4c097895cdfc5a427e5e4b3c01e0405c6647731384bbb9fd2747
7
- data.tar.gz: f7898ed1327fb0f316bda290f36b1cf8a4ab74d4d510243b276e408dd7d7d95e1e97e96e13d5c784b39c83c262574b57c34ce8b4d15983603e19194d477883fe
6
+ metadata.gz: 22998b27d0f277d174759d29c5087675628eee3a9a24ed460c82c87deda6a28252ed1d1ad78daee9a2438b2a7b1c4f8995197450d9eb5922b5689e58bda52bdb
7
+ data.tar.gz: 5ef7ef2f07269ca95bbd985c806250ca7d93d02c7c6bccd83a9d72a0c9a57b990bac63e977ddfbd1c601481562fb12d5c0272639ec415787cef0cc76fc4dd3c0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,635 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.8.2
4
+
5
+ A security and correctness patch. No new features beyond the `session:`
6
+ arguments and the cache opt-in the fixes need; intentional master-key and
7
+ analytics access is unchanged. Outcomes that change are listed under
8
+ Behavior Notes.
9
+
10
+ #### Transport and MCP authentication
11
+
12
+ - **FIXED**: A whitespace-only MCP API key (`api_key: " "`,
13
+ `MCP_API_KEY=" "`, or a non-breaking-space key) now counts as no key.
14
+ Before, it passed the check that refuses a non-loopback bind without a key
15
+ and then disabled request authentication, leaving a public MCP endpoint
16
+ open. The configured key and the provided `X-MCP-API-Key` header are
17
+ stripped the same way, an explicit blank `api_key:` no longer hides
18
+ `MCP_API_KEY`, and a non-loopback bind with a key shorter than 16
19
+ characters warns.
20
+ - **FIXED**: HTTPS and WebSocket scheme checks parse the URL and compare the
21
+ scheme case-insensitively. Before, `HTTP://` passed `require_https`,
22
+ `HTTPS://` with TLS verification turned off was not refused, and `WS://` got
23
+ past the plaintext LiveQuery guard.
24
+ - **FIXED**: The TLS guard also refuses `ssl: { verify_mode:
25
+ OpenSSL::SSL::VERIFY_NONE }`, `ssl: { verify_hostname: false }`, and a
26
+ `Faraday::SSLOptions` with verification off on an https server URL. Before,
27
+ only a Hash with `verify: false` was caught. A `faraday:` option passed as
28
+ `Faraday::ConnectionOptions` gets the same TLS and proxy guards as a Hash,
29
+ and any other non-Hash value raises.
30
+ - **FIXED**: `Parse::LiveQuery::Client.new` validates an explicit or
31
+ configured URL, as `Parse.setup` does. Plaintext `ws://` to a routable host
32
+ is refused without `allow_insecure`, and a URL whose scheme is not `ws`,
33
+ `wss`, `http`, or `https` raises `ArgumentError`. **CHANGED**: an
34
+ `https://` LiveQuery URL is used as `wss://` (it used to be opened without
35
+ TLS), and an `http://` URL as `ws://` under the same loopback rule, each
36
+ with a one-time deprecation warning.
37
+ - **FIXED**: `Parse::File` URLs hydrated from Parse JSON no longer skip the
38
+ `trusted_url_hosts` check when the scheme is uppercase (`HTTPS://`), the
39
+ URL has leading whitespace, or the URL is malformed (`https:host`,
40
+ `https:\\host`, `https:///host`), all of which browsers resolve to the
41
+ remote host. Under
42
+ `untrusted_url_policy = :raise` or `:strip`, a malformed http(s) URL is
43
+ treated as untrusted. `force_ssl` upgrades `HTTP://` URLs too. Hydration
44
+ reads a URL the way a browser does (tabs and newlines removed, leading
45
+ control characters stripped), checks scheme-relative values (`//host`,
46
+ `\\host`) against `trusted_url_hosts`, and treats `javascript:`, `data:`,
47
+ and other non-http(s) URLs as untrusted.
48
+ - **NEW**: `Parse::File.trust_legacy_tfss_on_any_host = false` requires
49
+ `tfss-` file URLs to come from `trusted_url_hosts`. The default (`true`)
50
+ keeps accepting them from any host.
51
+ - **FIXED**: `Parse::Response#inspect` prints only the status and the
52
+ result's shape, and `#to_s` replaces credential fields with `[FILTERED]`:
53
+ session tokens, passwords, `authData`, access and refresh tokens, API,
54
+ master, client, and JavaScript keys, MFA recovery codes and secrets
55
+ (`recovery`, `recoveryCodes`, `secret`, `authDataResponse`), and
56
+ storage-form columns (`_session_token`, `_hashed_password`,
57
+ `_email_verify_token`, `_perishable_token`, `_password_history`,
58
+ `_auth_data_*`). The same list drives log redaction, which also covers
59
+ header-shaped text (`X-Parse-Master-Key: ...`, `X-Parse-REST-API-Key`,
60
+ `X-Parse-Session-Token`, `X-Parse-Javascript-Key`, `X-Parse-Client-Key`,
61
+ `X-Parse-Webhook-Key`, `Authorization: Bearer ...`). `to_s` also filters
62
+ credential text inside string values (`password=...`), and its error form
63
+ redacts the request line and error text. Text patterns run on string values
64
+ before encoding, and a quoted value is redacted whole (spaces included,
65
+ and after an auth scheme: `password="a b"`, `Authorization: Bearer "x"`),
66
+ so the output stays valid JSON; log redaction of JSON bodies works the same
67
+ way. `#result` is unchanged.
68
+ - **IMPROVED**: Webhook endpoint registration accepts the scheme in any case
69
+ and warns when an `http://` endpoint on a routable host would carry the
70
+ webhook key in cleartext.
71
+
72
+ #### Batches and transactions
73
+
74
+ - **FIXED**: A batch keeps each write's credentials. Parse Server runs every
75
+ sub-request of a `POST /batch` under that call's own credentials and ignores
76
+ per-sub-request headers. Before, `Array#save`, `Array#destroy`,
77
+ `Parse.batch`, and `Parse::Object.transaction` sent everything through the
78
+ default client, so a write built for a user session (a class bound to
79
+ `Parse.client.become(token)`, or a `Parse::Request` with `session_token:` /
80
+ `use_master_key: false`) ran with the master key and bypassed ACLs and CLPs.
81
+ Writes are now sent through their own client and options, and a batch with
82
+ mixed credentials goes out as separate calls with responses in request
83
+ order. Credentials resolve exactly as for a single request: a request's
84
+ `session_token:` / `use_master_key:` options, its `X-Parse-Session-Token`
85
+ header, and its master-key suppression header (which keeps the master key
86
+ off even with `use_master_key: true`). The same rules apply to direct
87
+ `Parse::Client#batch_request` calls. A client chosen explicitly
88
+ (`BatchOperation#client=`, or the client `batch_request` is called on) is
89
+ used for every request, as `Parse::Client#request` uses it for a single
90
+ request; an object's class client applies only to implicit batches
91
+ (`Array#save`, `Array#destroy`, `Parse.batch`). A `use_master_key:` that is
92
+ not exactly `true` or `false` is treated as unset, a `session_token:` that
93
+ resolves to no token fails closed, and narrowing `batch_request` options
94
+ (`use_master_key: false`) override a request's own `use_master_key: true`.
95
+ - **FIXED**: `Parse::Object.transaction` runs with its objects' class client.
96
+ A transaction that mixes credentials raises
97
+ `Parse::BatchOperation::MixedAuthorityError` before anything is sent,
98
+ because Parse Server runs a transaction under one credential and it cannot
99
+ be split. Clients are compared by their credentials (server, application,
100
+ keys, and bound session), not by object identity, so classes whose clients
101
+ were configured identically, such as before and after a second
102
+ `Parse.setup`, still share one transaction. Requests that name their own
103
+ session token are compared without the master key or bound session, so
104
+ `transaction(session:)` across clients that differ only in those is sent as
105
+ one call.
106
+ - **FIXED**: `Array#destroy(session:)` on sessions looks up their tokens and
107
+ owners as that user, so deleting session ids the user cannot see no longer
108
+ evicts other users' cached identities or triggers a reset. A session the
109
+ caller could not read is still treated as deleted when its delete succeeds:
110
+ its recorded owner is invalidated, or the rate-limited reset applies. A
111
+ denied or not-found delete of such a row forgets nothing.
112
+ - **NEW**: `Array#save(session:)`, `Array#destroy(session:)`, and
113
+ `Parse::Object.transaction(session:)` send every write in the call as the
114
+ given user.
115
+
116
+ #### Agents
117
+
118
+ - **FIXED**: A session-token agent whose token could not be resolved when it
119
+ was built (for example, Parse Server unreachable) is no longer treated as
120
+ master-key posture by the SDK-side permission checks. The scope is resolved
121
+ again on first use; if it still cannot be, the call fails with
122
+ `Parse::Agent::UnresolvedIdentity` (reported as `:access_denied`,
123
+ `kind: :unresolved_identity`) instead of skipping the CLP check. Parse
124
+ Server already validated the token on REST calls.
125
+ - **FIXED**: `Parse::Agent#inspect`, `to_s`, and `pp` print a redacted summary
126
+ and no longer show the session token, the client's keys, the conversation,
127
+ or the last request and response.
128
+ - **FIXED**: `Parse::Agent#as_json`, `#to_json`, and `#to_yaml`, and the same
129
+ methods on `Parse::Client`, return a redacted summary, so JSON log
130
+ formatters and error trackers that serialize an agent or client no longer
131
+ emit its session token, master key, or REST key.
132
+ - **FIXED**: A sub-agent that holds its parent's session token on the
133
+ parent's client reuses the parent's resolved scope instead of resolving it again. A failed resolution
134
+ of a different child token is reported as `Parse::Agent::UnresolvedIdentity`,
135
+ not as an attempt to widen the parent's scope.
136
+ - **FIXED**: Lazy session resolution, `impersonate`, and `refresh_scope!` are
137
+ serialized per agent, so a concurrent `impersonate` cannot leave one
138
+ token's scope bound to another token. A failed lazy resolution is retried
139
+ after 5 seconds, not on every call. `refresh_scope!` resolves or raises for
140
+ a session agent instead of returning nil, and an invalid or expired token
141
+ gets its own error message. `acl_scope` checks and reads the scope under
142
+ the same lock, and `acl_permission_strings` never returns nil for a
143
+ session-token agent.
144
+ - **FIXED**: A session agent whose token cannot be resolved records the
145
+ failure at construction too, so the first use inside the 5-second window is
146
+ refused without a second lookup, and a token Parse Server rejected is not
147
+ retried. The lookup runs outside the agent's scope lock, so other threads
148
+ using the agent are not blocked for a request timeout, and the result binds
149
+ only if the token is unchanged. A sub-agent reuses its parent's scope only
150
+ from a token-and-scope snapshot taken under the parent's lock.
151
+ - **FIXED**: `Parse::Client` and `Parse::Agent` refuse `Marshal.dump`
152
+ (`TypeError`), so a client's master key or an agent's session token cannot
153
+ land in a marshaled cache or job payload.
154
+ - **CHANGED**: `Parse::Agent#stop_impersonating!` is a no-op on an agent that
155
+ is not impersonating, instead of dropping a constructor-supplied session
156
+ token and leaving master-key posture.
157
+
158
+ #### Response cache
159
+
160
+ - **FIXED**: Reads made with a session token are no longer served from the
161
+ response cache. A cached answer was returned without contacting Parse
162
+ Server, so a revoked session (logout, `Parse::Session#destroy`,
163
+ `logout_all!`, a password change), or a user who lost a role or row access,
164
+ kept reading the cached rows until the entry expired. Session reads now
165
+ always reach Parse Server, whether the session came from `session_token:`,
166
+ `Parse.with_session`, or a session-bound client. Master-key and anonymous
167
+ reads are cached as before, and writes made with a session still invalidate
168
+ cached entries.
169
+ - **NEW**: `Parse::Middleware::Caching.cache_session_requests = true` (or
170
+ per client, `Parse.setup(cache_session_requests: true)`, which overrides the
171
+ class default either way) opts back into caching session reads, for apps
172
+ that accept revocation being bounded by the cache TTL. The
173
+ `parse.cache.bypass` event includes `cache_tenant`.
174
+
175
+ #### Regex validation and direct reads
176
+
177
+ - **FIXED**: A raw `$regex` in a where hash
178
+ (`Model.query(name: { "$regex" => ... })`, `:field.eq`, `:field.not`,
179
+ `$elemMatch`, and `:or` branches) goes through the same ReDoS check as
180
+ `:field.like`. Before, only the regex constraints themselves were checked.
181
+ Ruby `Regexp` and BSON regex values under `$not`, `$in`, `$nin`, and `$all`
182
+ are checked too.
183
+ - **FIXED**: Mongo-direct filters and pipelines (`results_direct`,
184
+ `count_direct`, `Query#aggregate`, `Parse::MongoDB.aggregate` and `find`,
185
+ Atlas Search and vector filters, LiveQuery `where`, and the agent
186
+ `aggregate` tool) run every `$regex`, Ruby `Regexp`, BSON regex, and
187
+ `$regexMatch` / `$regexFind` / `$regexFindAll` pattern through the ReDoS
188
+ check. Before, only the pattern length was capped. The agent constraint
189
+ translator applies the same check.
190
+ - **FIXED**: `Parse::RegexSecurity` parses each caller-supplied pattern
191
+ instead of matching a few substrings. It refuses a group repeated more than
192
+ once whose body holds a variable-count quantifier (including an optional
193
+ `?`), an alternation, or a backreference (`(a+)+`, `(a|aa)+$`, `((a+))+`,
194
+ `(a+){2,}`, `(a+){20}`, `^(a?){100}a{100}$`), inline comments
195
+ `(?#...)`, extended mode, and patterns it cannot read. Before, several of
196
+ these shapes passed. Repeats of a single atom (`^.{1,255}$`, `\d{1,100}`)
197
+ and simple lookarounds (`^(?!test)`) are accepted; the old checker refused
198
+ them for `:field.like`. Escaped literal patterns
199
+ built by `contains`, `starts_with`, and `ends_with` get a longer length cap,
200
+ so long values keep compiling. It also refuses subroutine calls (`\g<1>`,
201
+ `\g'1'`), more than three unbounded or wide quantifiers in a row with no
202
+ separating literal they cannot match (`\d+\d+\d+\d+`, `.*a.*a.*a.*a`),
203
+ and whitespace inside a repeat count (`{ 2,}`), and reads `[:` that is not a
204
+ complete POSIX class as a literal, as PCRE2 does. A repeated group is
205
+ allowed when each repeat cannot split its input two ways: fixed
206
+ alternatives whose first characters differ even ignoring case
207
+ (`^(foo|bar)+$`, but not `(?i)^(a|Aa)+$`) or a literal
208
+ separator next to one run that cannot match it
209
+ (`^[a-z0-9]+(?:-[a-z0-9]+)*$`, `(\.[\w-]+)+`, `([\w-]+\.)+`).
210
+ - **FIXED**: `$regexMatch`, `$regexFind`, and `$regexFindAll` in
211
+ caller-supplied pipelines require a literal `regex` (a String that is not a
212
+ field path or variable, a Regexp, or a BSON regex). An expression or
213
+ `"$field"` operand raises `Parse::PipelineSecurity::Error`
214
+ (`reason: :regex_not_literal`).
215
+ - **IMPROVED**: Mongo-direct reads (`Parse::MongoDB.aggregate` and `find`,
216
+ `results_direct`, `count_direct`, Atlas Search, and vector and hybrid
217
+ search) whose filter or pipeline carries a regex that is not escaped literal
218
+ text get a server-side `max_time_ms` of
219
+ `Parse::PipelineSecurity.default_regex_max_time_ms` (5000 ms) when the
220
+ caller passed none. Set it to nil to turn this off; an explicit
221
+ `max_time_ms:` always wins. For REST queries, set Parse Server's
222
+ `databaseOptions.maxTimeMS`.
223
+ - **FIXED**: `Parse::MongoDB.find` raises `Parse::ACLScope::ACLRequired` inside
224
+ `Parse.without_master_key` instead of reading raw documents unscoped.
225
+ `Parse::MongoDB.indexes` (metadata only) stays available.
226
+
227
+ #### Webhooks
228
+
229
+ - **FIXED**: A webhook request's `master` flag is trusted only when the
230
+ request is authenticated: the webhook key matched, or a configured
231
+ signature verified. Under `Parse::Webhooks.allow_unauthenticated` with no
232
+ signature, a body claiming `"master": true` no longer passes master-only
233
+ field guards, ACL owner resolution, or handler `payload.master?` checks, and
234
+ no longer triggers the Ruby-initiated callback dedup, so a forged `_RB_`
235
+ request id cannot make a write skip model callbacks (including a
236
+ `before_save` that rejects it). Only a JSON `true` counts as master.
237
+ `payload.authenticated?` reports whether the request was authenticated, and
238
+ `payload.claimed_master?` exposes the raw claim; both are for diagnostics
239
+ only. Signature verification and the authenticated flag come from one read
240
+ of the signing secret.
241
+
242
+ #### Behavior Notes
243
+
244
+ - **Transport.** A LiveQuery `https://` URL is used as `wss://` (and a
245
+ loopback `http://` as `ws://`) with a deprecation warning; configure the
246
+ `ws(s)://` URL directly. Other LiveQuery schemes raise. `HTTP://` with
247
+ `require_https`, and an https server URL with TLS verification disabled
248
+ (`verify: false`, `verify_mode`, `verify_hostname`, or `SSLOptions`), raise.
249
+ Loopback hosts for these checks are `localhost`, `127.0.0.0/8`, `::1`, and
250
+ `0.0.0.0`, parsed as addresses (`127.999.1.1` and `localhost.` are not
251
+ loopback). A padded MCP API key is enforced without its surrounding
252
+ whitespace. Hydrated `Parse::File` URLs with an uppercase scheme or leading
253
+ whitespace are checked against `trusted_url_hosts`; malformed http(s) URLs
254
+ are refused or stripped under `:raise` / `:strip` and warn under `:warn`,
255
+ as are non-http(s) schemes (`javascript:`, `data:`, `file:`) and
256
+ scheme-relative values pointing at an untrusted host.
257
+ - **Printing.** `Parse::Response#to_s` replaces credential-bearing values with
258
+ `"[FILTERED]"` and `#inspect` no longer shows values; read `#result` (or
259
+ `#result.to_json`) for raw data. `to_s` may also filter text in string
260
+ values that looks like a credential (`password=...`). `Parse::Agent` and `Parse::Client`
261
+ `inspect`, `to_s`, `as_json`, `to_json`, and `to_yaml` print redacted
262
+ summaries.
263
+ - **Agents.** A session-token agent that cannot resolve its token refuses
264
+ tool calls that need its permissions. `Agent#acl_scope` can now raise
265
+ `Parse::Agent::UnresolvedIdentity`, and `acl_scope?` is true for any
266
+ session-token agent; custom handlers that test `agent.acl_scope` for
267
+ truthiness should use `agent.acl_scope?`. A sub-agent of an unresolved
268
+ parent can inherit the parent's token, but one with a different identity
269
+ raises until the parent's token resolves. A session agent built while Parse
270
+ Server is unreachable refuses calls for 5 seconds before retrying.
271
+ Marshaling a `Parse::Client` or `Parse::Agent` raises `TypeError`; store the
272
+ configuration and build a new client instead.
273
+ - **Response cache.** Queries that set `cache: true` (or run under
274
+ `Parse.default_query_cache = true`) with a session token no longer get cache
275
+ hits. Set `cache_session_requests` to `true` (only a literal `true`
276
+ enables it) to restore the old behavior, and keep `expires:` short if you
277
+ do.
278
+ - **Regex.** A raw `$regex`, or a `Regexp` in a mongo-direct filter, that the
279
+ ReDoS check rejects now raises: `ArgumentError` on REST queries,
280
+ `Parse::PipelineSecurity::Error` (or `Parse::MongoDB::DeniedOperator`) on
281
+ direct paths. Escaped literal patterns, including those built by
282
+ `starts_with`, `ends_with`, and `contains`, pass the shape check and get a
283
+ length cap about twice the normal one. Caller-supplied `$options` accept
284
+ `i`, `m`, `s`, and `u`; an unknown flag, a non-String value, the extended
285
+ flag `x`, or a Regexp built with `Regexp::EXTENDED` raises. The agent
286
+ translator now accepts `s` and `u` and no longer accepts `x`. A pattern with
287
+ more than three unbounded quantifiers in a row and no literal between them
288
+ (for example `^\w+\s+\w+\s+\w+\s+\w+$`) is refused; add a literal
289
+ separator or split the query. A mongo-direct read carrying a non-literal
290
+ regex and no `max_time_ms` times out after 5 seconds with
291
+ `Parse::MongoDB::ExecutionTimeout`; pass `max_time_ms:` or set
292
+ `Parse::PipelineSecurity.default_regex_max_time_ms` to change it.
293
+ - **Direct reads.** Code that calls `Parse::MongoDB.find` inside
294
+ `Parse.without_master_key` should wrap it in `Parse.with_master_key` or use
295
+ a scoped read. `Parse::MongoDB.collection` returns raw driver access with no
296
+ scope and is not refused inside the block.
297
+ - **Batches.** A batch or transaction of objects whose class is bound to a
298
+ session client now runs as that user, as a single save does. A transaction
299
+ mixing credentials raises `MixedAuthorityError`, and a non-transactional
300
+ batch with mixed credentials is sent as several calls. An explicitly chosen
301
+ batch client (`client=` or the `batch_request` receiver) is used for every
302
+ request instead of each object's class client.
303
+ `Client#batch_request` accepts `session_token:` and `use_master_key:`. A
304
+ blank `session:`, or a user with no session token, raises `ArgumentError`.
305
+ - **Webhooks.** `payload.master?` and `payload.claimed_master?` no longer
306
+ treat the string `"true"` as master; Parse Server always sends a JSON
307
+ boolean. Under `Parse::Webhooks.allow_unauthenticated` with no signing
308
+ secret, `payload.master?` is false even for a master-key request Parse
309
+ Server forwards, so master-only field guards and handler `master?` checks
310
+ refuse it, and saves made by the SDK itself run their model callbacks in
311
+ process and again through the webhook. Configure a webhook key
312
+ (`PARSE_SERVER_WEBHOOK_KEY` on Parse Server and `Parse::Webhooks.key` in the
313
+ app) or a signing secret to restore master trust and dedup.
314
+
315
+ ### 5.8.1
316
+
317
+ A correctness release for webhooks, sessions, associations, queries, search,
318
+ and agent policies. No new defaults and no API changes; every corrected
319
+ outcome is listed under Behavior Notes.
320
+
321
+ #### Webhooks
322
+
323
+ - **FIXED**: A client create through a `before_save` webhook whose handler
324
+ returns `parse_object` now gets the ACL its class policy resolves, exactly
325
+ as an SDK-side create would. Owner-based policies (`:owner_else_private`,
326
+ the default, plus `:owner_else_public` and `:owner_but_public_read`) take the
327
+ owner from the declared `owner:` field, or from the requesting user when that
328
+ field is empty. Before, a signed-in client got the policy's no-owner
329
+ fallback (`{}` under `:owner_else_private`) and could not read its own
330
+ record. A request without a user, or a master-key request, gets the policy
331
+ fallback. A `_User` class declared with `owner: :self` is owned by the new
332
+ user, never the requester. An ACL the client sent is kept, and an ACL the
333
+ handler sets always wins (through `acl=` or mass assignment), even when it
334
+ equals the class default. A `guard :acl, :master_only` revert on a create
335
+ returns the ACL to the class policy instead of `{}`. A handler-assigned ACL
336
+ is written whatever the handler returns (`parse_object`, `true`, `nil`, or
337
+ a Hash that does not set `ACL` itself).
338
+ - **FIXED**: When a `before_save` handler changes a record, a sub-document the
339
+ client changed is written back as one-level dotted paths (`"meta.count":
340
+ {...}`) instead of the whole field, so concurrent writes to other sub-keys
341
+ survive. A removed sub-key is written as a `Delete`. Dotted keys in a
342
+ returned Hash are folded into the field or `field.sub` write they belong to,
343
+ with operators (`Increment`, `Add`, `AddUnique`, `Remove`, `Delete`) applied
344
+ rather than stored. Hash keys may use Ruby names.
345
+
346
+ #### Sessions and master-key scope
347
+
348
+ - **FIXED**: Deleting `Parse::Session` or `Parse::User` objects, one at a time
349
+ or with a batch `Array#destroy`, drops their identity-cache entries when the
350
+ delete succeeds or reports the row already gone. A denied delete leaves the
351
+ cache alone. A session that does not carry its token or owner (built from
352
+ its objectId alone, fetched without the master key, or fetched with `keys:`
353
+ leaving them out) has them looked up first, in one query per batch: with the
354
+ master key when the client has one, otherwise with the delete's `session:`.
355
+ A lookup that finds no row leaves nothing to forget; only a lookup that
356
+ fails resets the client's identity cache, at most once every 5 seconds per
357
+ client. When the lookup finds no row, the delete uses the owner recorded
358
+ when a `_Session` query or `Parse::Session.session` last fetched that
359
+ session through the same client (only the user id, never the token, in a
360
+ store separate from the identity plane,
361
+ `client.authorization.session_owner_cache`). Without one, a batch delete
362
+ that applied or reported the row gone falls back to the rate-limited reset;
363
+ a denied delete never resets, and a single `destroy` never resets. The
364
+ looked-up token is used only for the delete, is never kept on the object,
365
+ and never enters the response cache. A token resolution in flight when an
366
+ invalidation lands, in this process or, on the Redis identity plane, in any
367
+ other, does not cache its answer: the resolver checks a shared invalidation
368
+ marker before and after writing and evicts its own write when the marker
369
+ moved.
370
+ - **FIXED**: `Parse::User#signup!`, signup on save, and `upgrade_anonymous!`
371
+ store `created_at` / `updated_at` as `Parse::Date` values. Reading
372
+ `updated_at` on a newly signed-up user no longer marks it dirty.
373
+ - **FIXED**: Inside `Parse.without_master_key`, an explicit `master: true`
374
+ passed to `Parse::MongoDB.aggregate`, `Parse::ACLScope`, `Parse::Query`
375
+ direct reads, vector and hybrid search, Atlas Search, or agent tools for
376
+ master-key agents no longer runs as master. The call runs in the public
377
+ scope, as the REST request does once the block strips its master key, and
378
+ the `parse.mongodb.aggregate` notification reports `master_dropped: true`.
379
+ The SDK's own metadata reads keep the master key without lifting the block:
380
+ the class schema and role graph read to enforce a scope, index statistics,
381
+ and the Atlas Search index listing. Before, a schema read refused inside the
382
+ block was cached and denied every scoped mongo-direct read of that class,
383
+ for all callers, for 5 seconds. `Parse::AtlasSearch.faceted_search` raises
384
+ `FacetedSearchNotACLSafe` inside the block, because its bucket counts are
385
+ not ACL-filtered. The metadata marker rides only on the outgoing request
386
+ copy, never on `response.request` or an error's request. A schema read that
387
+ fails inside the block is retried at most every 2 seconds there. A LiveQuery
388
+ admin client that first connected inside the block keeps withholding its
389
+ master key on every reconnect until `allow_master_key_connection!`.
390
+
391
+ #### Agent policies
392
+
393
+ - **FIXED**: A sub-agent's `tools:` and `methods:` filters now only narrow its
394
+ parent's, as `classes:` already did. A child `tools: { only: }` is
395
+ intersected with the parent's allowlist and `except:` sets are unioned; for
396
+ `methods:` the parent's filters stay in force alongside the child's. Before,
397
+ the child's filter replaced the parent's, so a sub-agent could enable a tool
398
+ or `agent_method` within its tier that the parent was not given. A child
399
+ `only:` list with nothing its parent allows raises `ArgumentError` at
400
+ construction.
401
+ - **FIXED**: A sub-agent built with `parent:` uses its parent's approval gate,
402
+ read at call time, unless given its own. Before, it fell back to
403
+ `NullGate`, so a sub-agent built inside an MCP `tools/call` ran tools in
404
+ `require_approval_for` tiers without approval.
405
+ - **FIXED**: Qualified `methods:` entries compare the class by its Parse class
406
+ name, so `"User.x"` and `"_User.x"` (and the `Role`, `Session`, and
407
+ `Installation` aliases) match whichever spelling the entry or the
408
+ `call_method` caller uses. Before, `methods: { except: ["_User.x"] }` could
409
+ be bypassed by calling the method on `"User"`.
410
+ - **FIXED**: A sub-agent can no longer turn on `master_atlas` when its parent
411
+ does not have it. `Parse::Agent.new(parent: scoped_parent, master_atlas:
412
+ true)` raises `ArgumentError`; before, the child's `atlas_faceted_search` ran
413
+ with master-key semantics and returned every row under the parent's scoped
414
+ identity. Omitting `master_atlas` still inherits, and `false` still drops
415
+ it.
416
+ - **FIXED**: The MCP dispatcher snapshots and restores an agent's own approval
417
+ gate, so driving a sub-agent through `tools/call` no longer pins its
418
+ parent's gate onto it. Assigning `approval_gate = nil` makes a sub-agent use
419
+ its parent's gate again.
420
+ - **FIXED**: Qualified `methods:` entries may name a model by its Ruby
421
+ constant (`"Artist.purge"` for a class whose `parse_class` is `"Musician"`)
422
+ and are compared by Parse class name, so such an `except:` entry is no
423
+ longer bypassed through the Parse class name. An entry whose class resolves
424
+ to no loaded model warns at construction.
425
+ - **IMPROVED**: The `parse.agent.tool_call` audit payload adds
426
+ `methods_layers` whenever a sub-agent inherits a `methods:` filter
427
+ (including a single parent layer), and `describe[:methods][:layers]` lists
428
+ every `methods:` filter in force, parent first.
429
+
430
+ #### Associations
431
+
432
+ - **FIXED**: `add!`, `add_unique!`, and `remove!` on a
433
+ `has_many through: :array` collection take the array Parse Server returns,
434
+ as plain array properties already did. A stale local copy is corrected,
435
+ objects already fetched locally are kept, and the field is not marked
436
+ changed. The array is adopted only when every entry is a pointer to the
437
+ declared class; otherwise the operation is applied locally.
438
+ - **FIXED**: Saving a relation collection on its own (`owner.likes.save`)
439
+ sends the staged additions and removals and returns whether they were
440
+ applied. Before, it silently sent nothing.
441
+ - **FIXED**: `Parse::Object#clear_changes!` (also called by `reload!`) drops
442
+ a relation's staged additions and removals and clears array collection
443
+ dirty state. Before, discarded relation changes were sent with the next
444
+ change to that relation. A fetch (`fetch!`, partial fetch, autofetch, with
445
+ or without `preserve_changes:`) keeps a relation's staged additions and
446
+ removals and leaves the relation marked changed, including when the response
447
+ carries the relation's descriptor, so the next save sends them.
448
+ `rollback!` drops a relation's staged additions and removals too, including
449
+ after a fetch.
450
+ - **IMPROVED**: `RelationCollectionProxy#save` accepts `session:` to send the
451
+ staged operations as a given user (`nil` for no session token). Relation
452
+ writes on an owner whose objectId was assigned client-side during a create
453
+ are staged rather than sent until the create returns.
454
+ - **FIXED**: Atomic relation operations (`add!` / `remove!` on a
455
+ `through: :relation` collection) write to the relation's Parse column when
456
+ its name differs from the Ruby name (`has_many :liked_items` maps to
457
+ `likedItems`, or a relation declared with `field:`). Before, they targeted
458
+ the Ruby name.
459
+
460
+ #### Queries
461
+
462
+ - **FIXED**: An OR whose branch has no constraints now matches every row.
463
+ Previously `query | Model.query` and `Parse::Query.or(query, Model.query)`
464
+ dropped that branch and returned only the other side. An empty receiver of
465
+ `|` still starts a new OR, as `or_where` does, so
466
+ `[q1, q2].reduce(Model.query, :|)` builds `q1 OR q2`.
467
+ - **FIXED**: `Model.query(:or => [{...}, {...}])` builds a `$or` group ANDed
468
+ with the query's other constraints, instead of a constraint on a literal
469
+ field named `or`. Branches can be constraint hashes, `Parse::Query` objects
470
+ of the same class, or arrays of constraints.
471
+ - **FIXED**: A constraint that needs the aggregation pipeline or mongo-direct
472
+ (`readable_by`, `:ACL.readable_by`, `:field.array_size`, the mongo-direct geo
473
+ operators) raises `ArgumentError` inside an OR (`:or`, `or_where`, `|`,
474
+ `Parse::Query.or`). Before, the branch was emitted as `{}` and matched every
475
+ row, or lost part of its conditions, including in push targeting, which
476
+ could reach every installation.
477
+ - **FIXED**: `Parse::Query#clone`, `|`, `or_where`, `Parse::Query.or`, `:or`
478
+ branches (including Hash branches with `session:` / `use_master_key:` keys
479
+ or nested scoped queries), and subqueries (`$inQuery`, `$notInQuery`,
480
+ `$select`, `$dontSelect`) keep the query's authority and application:
481
+ session token, a session-bound client from `Parse::Client#become`, scoped
482
+ user or role, master-key flag, read preference, and the Parse application
483
+ the query targets. Queries combined this way must run under the same
484
+ authority against the same application. An unscoped query takes the
485
+ other's authority, and different authorities or applications raise
486
+ `ArgumentError`. Before, a session combined with `scope_to_user` ran as the
487
+ scoped user, a `become`-client query composed into an OR ran as the master
488
+ key, a scoped subquery ran under the outer query's master key, and a clone
489
+ could send one application's session token to the default application.
490
+ Changing the authority or client of a combined query raises and leaves the
491
+ query unchanged. A combined query re-checks its authority before it runs,
492
+ and explicit auth passed to `results_direct`, `count_direct`,
493
+ `distinct_direct`, or `atlas_search` must match it.
494
+ - **FIXED**: LiveQuery subscriptions and push targeting refuse constraints that
495
+ only the aggregation pipeline or mongo-direct can run (`array_size`,
496
+ `set_equals`, `not_empty`, `elem_match`, `readable_by`, mongo-direct geo
497
+ operators). Before, those constraints were dropped and the subscription or
498
+ push reached a wider audience. A subquery with such a constraint is also
499
+ refused, where it used to match every row. `Parse::Query#subscribe` and
500
+ `client.subscribe(query)` run under the query's own session or `become`
501
+ client, and such a subscription is refused with `ArgumentError` when the
502
+ LiveQuery client targets a different Parse application id than the query,
503
+ so one application's session token is never sent to another application's
504
+ LiveQuery server.
505
+ - **FIXED**: A field repeated in `order` is sent once, at its first position
506
+ with the last direction given, matching what MongoDB applies on direct
507
+ reads. String forms such as `"-title"` are parsed as descending, so they
508
+ deduplicate too, and the direct `$sort` stage sorts on the real field.
509
+ `order("title,-plays")` is parsed as two fields.
510
+ - **FIXED**: A bare objectId compared to a declared pointer field
511
+ (`Song.query(:artist => "abc123")`, `:artist.ne`, `:artist.in`) now matches
512
+ on REST, in `Parse::Push` targeting, and in LiveQuery subscriptions from
513
+ `Parse::Query#subscribe`, `Model.subscribe`, and `client.subscribe(query)`.
514
+ Equality and `$in` used to return no rows and `$ne` every row, while direct
515
+ and aggregate reads already matched. The new
516
+ `Parse::Query#compile_rest_where` returns the where clause in that form.
517
+
518
+ #### Search
519
+
520
+ - **FIXED**: Atlas Search (`Parse::AtlasSearch.search`, `search_with_stage`,
521
+ `autocomplete`, `Parse::Query#atlas_search`) and native hybrid search apply
522
+ the CLP `pointerFields` / `readUserFields` ownership constraint before the
523
+ page is cut. For native hybrid, the lexical input filters before its
524
+ per-input `$limit`, and the fused result is filtered again before the final
525
+ `$limit`. Rows used to be filtered after the page was cut, so when the
526
+ top-ranked hits belonged to other users a scoped caller got a short or empty
527
+ page. Inaccessible rows are still never returned.
528
+ - **IMPROVED**: `Parse::VectorSearch.search` and native hybrid search enforce
529
+ the `pointerFields` ownership constraint server-side across the whole
530
+ candidate window. When every owner field is declared as a scalar pointer
531
+ (`belongs_to`) and its storage path (`_p_<field>`) is a `type: "filter"`
532
+ field of the vectorSearch index, the constraint also goes
533
+ into `$vectorSearch.filter`, so other users' rows no longer use up the
534
+ candidate window. The prefilter is used only while the index is READY and
535
+ serving its latest definition. If Atlas refuses it (for example mid-rebuild),
536
+ the search reruns once without it and skips it for the index cache TTL. A
537
+ failed index lookup is also remembered for that TTL.
538
+
539
+ - **FIXED**: A `$vectorSearch` refusal caused by the caller's own
540
+ `vector_filter` on an unindexed path no longer switches off the owner
541
+ prefilter for every caller on that index or clears the index cache; only a
542
+ refusal naming an owner `_p_<field>` path does. Native hybrid search uses
543
+ `Parse::VectorSearch.default_index` when the vector branch names no index.
544
+
545
+ #### Documentation
546
+
547
+ - **FIXED**: README and query YARD examples use `:field.pointer_id` and
548
+ `:field.array_size`, the names the DSL has used since 5.8.0.
549
+
550
+ #### Behavior Notes
551
+
552
+ - An OR that includes an unconstrained branch now returns every row. The one
553
+ exception is an empty receiver of `|`, which starts the OR. Code that passed
554
+ an empty query for "no filter" should leave that branch out. `:or => []`
555
+ matches no rows.
556
+ - A pipeline-only constraint inside an OR now raises `ArgumentError`. Apply it
557
+ outside the OR, or run the branches as separate queries.
558
+ - Combining queries that run under different authorities or against
559
+ different Parse applications now raises `ArgumentError`. That covers OR
560
+ composition, `:or` Hash branches, and subqueries, and includes different
561
+ session tokens, a session against `scope_to_user` / `scope_to_role` / the
562
+ master key, and different scoped users or roles. Setting a new session
563
+ token, master-key flag, scope, or client on a query that took its authority
564
+ from another query also raises, as does passing different explicit auth to
565
+ a direct or Atlas terminal. Query-shaping options (`limit`, `order`, `keys`,
566
+ `skip`, `include`) inside an `:or` branch raise.
567
+ - LiveQuery subscriptions, push targeting, and subqueries with pipeline-only
568
+ constraints raise `ArgumentError`. Subscribing to a `scope_to_user` /
569
+ `scope_to_role` query, or passing a `session_token:` that differs from the
570
+ query's session, raises.
571
+ - REST queries, LiveQuery subscriptions, and push targeting that compare a
572
+ bare objectId to a declared pointer field now return the matching rows.
573
+ Undeclared fields are unchanged. If a model declares `belongs_to` for a
574
+ field whose server column holds a plain String, a bare string compared to
575
+ that field is now sent as a Pointer and stops matching those values;
576
+ declare such a field as a `:string` property instead.
577
+ - Code that relied on `master: true` keeping master authority inside
578
+ `Parse.without_master_key` should wrap that call in `Parse.with_master_key`.
579
+ A dropped `master: true` raises `ACLRequired` under either
580
+ `Parse::ACLScope.require_session_token` or
581
+ `Parse::AtlasSearch.require_session_token`, with a message naming the block,
582
+ and no longer emits the no-ACL banner. The role-graph helpers
583
+ (`users_in_role_subtree` and related) raise `ACLRequired` for
584
+ `master: true` inside the block; pass `as:` instead.
585
+ - A LiveQuery connection that already connected with the master key outside
586
+ `Parse.without_master_key` stays elevated for subscriptions made inside the
587
+ block, because Parse Server authorizes per connection; the SDK warns.
588
+ - `Parse.without_master_key` guards against accidental master-key use and is
589
+ not an isolation boundary.
590
+ - `Parse::Role.all_for_user` and `Parse::Role#all_parent_role_names` without
591
+ `master:` or `as:` return the full role closure inside
592
+ `Parse.without_master_key`, because the role graph is read as SDK metadata.
593
+ - `Parse::Session._preload_identity_for_destroy!` and the
594
+ `_after_batch_destroy` hooks are now private.
595
+ - A session deleted elsewhere whose owner this process never recorded keeps
596
+ resolving until the next rate-limited reset or `identity_cache_ttl`.
597
+ - A custom identity plane gets the cross-process revocation-race guarantee by
598
+ implementing `invalidation_nonce` and `bump_invalidation_nonce`, as the
599
+ built-in Redis plane does. One with generation counters only is covered for
600
+ single-token and per-user invalidations, but a lookup racing another
601
+ process's full reset is bounded by `identity_cache_ttl` when the plane's
602
+ `clear` also deletes its counters.
603
+ - A sub-agent's `tools:` or `methods:` `only:` list that leaves nothing once
604
+ the parent's `only:` and `except:` lists apply now raises `ArgumentError`.
605
+ An explicitly empty `only: []` is accepted. A sub-agent passing
606
+ `master_atlas: true` under a parent without it raises `ArgumentError`.
607
+ - Parse Server sends a `before_save` webhook only the resulting value of a
608
+ dotted operator. When the handler changes the record, the SDK writes back
609
+ changed sub-keys one level deep and deletes removed ones by path. Two
610
+ concurrent writes inside the same sub-key (including deeper paths under it)
611
+ can still overwrite each other, an array is written whole, and a changed
612
+ sub-key carrying a typed value (Date, Bytes, Pointer) makes the whole field
613
+ be written. A client's whole replace of a sub-document becomes a merge. The
614
+ save response reports the dotted keys; the JavaScript SDK applies them, and
615
+ other clients may need a fetch. A handler operator that cannot be folded,
616
+ or two conflicting dotted keys from the handler, fail the save with an
617
+ error.
618
+ - A `before_save` handler that returns `true`, `nil`, or a Hash keeps the
619
+ client's write as sent, so a client create without an ACL through such a
620
+ handler is stored public read and write, as before, unless the handler
621
+ assigns an ACL on `parse_object`.
622
+ - Saving a relation collection on its own sends removals and additions as two
623
+ requests. If the second fails, the first stays applied and the unsent
624
+ operations stay staged.
625
+ - Owner fields stored as arrays of pointers, undeclared, or not filter-indexed
626
+ get no `$vectorSearch` prefilter. Vector search then fills results only from
627
+ its existing candidate window.
628
+ - A custom identity cache with neither `invalidate_value` nor generation
629
+ support (`generation`, `generation_current?`, `bump_generation`) only
630
+ expires a user's tokens at `identity_cache_ttl` after a password change,
631
+ account deletion, `logout_all!`, or a batch destroy of sessions.
632
+
3
633
  ### 5.8.0
4
634
 
5
635
  #### Breaking Changes