parse-stack-next 5.0.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  4. data/.github/dependabot.yml +13 -0
  5. data/.github/workflows/codeql.yml +1 -1
  6. data/.github/workflows/docs.yml +3 -3
  7. data/.github/workflows/release.yml +14 -3
  8. data/.github/workflows/ruby.yml +1 -1
  9. data/.gitignore +1 -0
  10. data/.yardopts +19 -0
  11. data/CHANGELOG.md +792 -0
  12. data/Gemfile +3 -0
  13. data/Gemfile.lock +8 -5
  14. data/README.md +15 -0
  15. data/Rakefile +5 -1
  16. data/docs/acl_clp_guide.md +553 -0
  17. data/docs/atlas_vector_search_guide.md +123 -22
  18. data/docs/client_sdk_guide.md +201 -5
  19. data/docs/usage_guide.md +21 -0
  20. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  21. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  22. data/lib/parse/agent/tools.rb +153 -1
  23. data/lib/parse/cache/redis.rb +53 -0
  24. data/lib/parse/client/caching.rb +18 -1
  25. data/lib/parse/client.rb +79 -12
  26. data/lib/parse/embeddings/cohere.rb +143 -6
  27. data/lib/parse/embeddings/provider.rb +20 -2
  28. data/lib/parse/embeddings/voyage.rb +102 -0
  29. data/lib/parse/embeddings.rb +332 -1
  30. data/lib/parse/live_query/client.rb +167 -4
  31. data/lib/parse/live_query/configuration.rb +12 -0
  32. data/lib/parse/live_query/subscription.rb +55 -2
  33. data/lib/parse/live_query.rb +123 -1
  34. data/lib/parse/lock.rb +342 -0
  35. data/lib/parse/lock_backend.rb +308 -0
  36. data/lib/parse/model/classes/audience.rb +5 -0
  37. data/lib/parse/model/classes/installation.rb +122 -0
  38. data/lib/parse/model/classes/job_schedule.rb +3 -1
  39. data/lib/parse/model/classes/job_status.rb +4 -1
  40. data/lib/parse/model/classes/push_status.rb +4 -1
  41. data/lib/parse/model/classes/session.rb +7 -0
  42. data/lib/parse/model/classes/user.rb +204 -0
  43. data/lib/parse/model/core/create_lock.rb +28 -146
  44. data/lib/parse/model/core/embed_managed.rb +162 -13
  45. data/lib/parse/model/core/parse_reference.rb +17 -1
  46. data/lib/parse/model/core/querying.rb +26 -2
  47. data/lib/parse/model/file.rb +523 -18
  48. data/lib/parse/query.rb +31 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +98 -1
  51. data/parse-stack-next.gemspec +2 -2
  52. metadata +17 -7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,797 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.1.0
4
+
5
+ #### `Parse::File` — URL normalization, presigned-URL stash, leak hardening
6
+
7
+ The model is hardened so signed URLs never persist in `@url` — they
8
+ get stripped to a canonical bare URL and stashed separately with a
9
+ data-driven expiry parsed from the URL's own query parameters. The
10
+ single URL normalization point applies uniformly to every writer
11
+ (caller-side `url=`, hydration `attributes=`) so the rule is the same
12
+ whether the file pointer arrived from Parse Server's REST surface
13
+ (which may include a freshly-signed URL when Parse Server's
14
+ S3FilesAdapter is configured with `presignedUrl: true`) or from a
15
+ direct caller-side assignment. The change also lays groundwork for
16
+ pluggable storage-adapter work in a later release without committing
17
+ that surface area in this one.
18
+
19
+ **Migration callout — `@url` value change:** the `@url` field now
20
+ drops signed-URL query parameters before storage. Any application
21
+ code that assigned a presigned URL directly to `Parse::File#url=`
22
+ (uncommon, but possible when wiring up custom file-serve flows) will
23
+ find that `file.url` now returns the bare canonical URL; the
24
+ original signed URL is available via the new `file.presigned_url`
25
+ accessor with its expiry in `file.presigned_url_expires_at`. The
26
+ `Parse::File#to_s` / `<%= file %>` ERB rendering path is unchanged
27
+ in shape (still returns `@url`), but the value emitted is now the
28
+ canonical bare URL rather than whatever was assigned — apps relying
29
+ on inline ERB to render a freshly-signed URL must switch to
30
+ `file.presigned_url` (or the new
31
+ `Parse::File#presigned_url_valid?` predicate) explicitly.
32
+
33
+ **Migration callout — error reporter payload shape:**
34
+ `Parse::File#inspect` no longer includes the URL at all. Anything
35
+ that captures exception inspect output — Sentry, Honeybadger,
36
+ Rollbar, Bugsnag, Rails' default error pages, custom log scrubbers
37
+ — will see a different payload shape the day an app upgrades to
38
+ 5.1.0. Tests that pattern-matched on `@url='https://...'` in
39
+ inspect output will need to be updated, and dashboards / alerts
40
+ that grouped errors by inspect string fingerprints will see a
41
+ one-time shift in fingerprint values. The new format emits
42
+ `@name`, `@mime_type`, `@contents` (presence), and `@url=set|blank`
43
+ — enough to debug, none of the URL content.
44
+
45
+ - **NEW**: `Parse::File#url=` and `Parse::File#attributes=` now route
46
+ through a single private normalization point. When the incoming URL
47
+ carries a recognized signed-URL query parameter
48
+ (`X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token`,
49
+ `AWSAccessKeyId`, `Key-Pair-Id`), the query string is stripped
50
+ entirely; the bare canonical URL is stored in `@url`; the original
51
+ signed URL is stashed in `@presigned_url` with its expiry parsed
52
+ into `@presigned_url_expires_at`. The expiry is data-driven —
53
+ computed from `X-Amz-Date + X-Amz-Expires` (SigV4) or `Expires`
54
+ (SigV2 / CloudFront) — never hardcoded SDK-side. The `@url`
55
+ invariant is now structural: the field never holds a short-TTL
56
+ signed URL. (`lib/parse/model/file.rb`)
57
+ - **NEW**: `Parse::File#presigned_url` and
58
+ `Parse::File#presigned_url_expires_at` accessors expose the
59
+ stashed signed URL and its parsed expiry. Useful today for apps
60
+ with Parse Server's `S3FilesAdapter` configured with
61
+ `presignedUrl: true`: the SDK normalizes the URL Parse Server
62
+ hands back on every read, the bare canonical value lands in
63
+ `@url`, and the freshly-signed URL is available via
64
+ `file.presigned_url` until `file.presigned_url_expires_at`. The
65
+ stash is invalidated automatically on any URL reassignment
66
+ (signed → canonical, signed → signed, or assignment to nil), so
67
+ callers reading `file.presigned_url` are never handed a value
68
+ staler than `@url`. (`lib/parse/model/file.rb`)
69
+ - **NEW**: `Parse::File#presigned_url_valid?(buffer: 60)` returns
70
+ true when `@presigned_url` is set and `@presigned_url_expires_at`
71
+ is at least `buffer` seconds in the future. Default 60 seconds —
72
+ a margin that absorbs network RTT, client clock skew, and one
73
+ retry. Eliminates the hand-rolled `expires_at && expires_at -
74
+ Time.now.utc > N` pattern every caller would otherwise write to
75
+ gate a refetch. (`lib/parse/model/file.rb`)
76
+ - **NEW**: `Parse::File.signed_url_policy` global accessor controls
77
+ how the URL normalization point reacts to incoming signed URLs.
78
+ Values: `:strip` (default — strip and stash, the pragmatic
79
+ behavior for any deployment where Parse Server's S3FilesAdapter
80
+ returns presigned URLs on read) or `:raise` (refuse the
81
+ assignment with `SignedUrlError`). Strict mode for apps that can
82
+ guarantee Parse Server is NOT issuing signed URLs and want a
83
+ loud failure on any signed-URL assignment instead of silent
84
+ normalization. The policy applies uniformly to both
85
+ caller-side `url=` and hydration `attributes=` — asymmetric
86
+ writer behavior was an explicit anti-goal. (`lib/parse/model/file.rb`)
87
+ - **NEW**: `Parse::File.parse_presigned_expiry(url)` class method
88
+ extracts the expiry time (UTC) from any signed URL by parsing its
89
+ own query parameters. Supports SigV4 and SigV2 / CloudFront
90
+ shapes. Returns nil for URLs without parseable presigned-URL
91
+ expiry data. (`lib/parse/model/file.rb`)
92
+ - **FIXED**: `Parse::File#saved?` basename comparison now strips the
93
+ URL's query string before computing basename, so short-TTL
94
+ presigned URLs that Parse Server's S3FilesAdapter returns on every
95
+ read (`https://bucket.s3.../doc.pdf?X-Amz-Signature=...`) don't
96
+ break `saved?` on reload. The signature bytes used to leak into
97
+ the comparison and cause false negatives.
98
+ (`lib/parse/model/file.rb`)
99
+ - **FIXED**: `Hash#parse_file?` strips the URL's query string before
100
+ the basename equality check so presigned URLs round-trip cleanly
101
+ through file-pointer recognition. Previously the signature bytes
102
+ leaked into the comparison and could cause false negatives.
103
+ (`lib/parse/model/file.rb`)
104
+ - **FIXED**: `Parse::File#save` now routes the file-create response
105
+ URL through the same normalization point as `url=` / `attributes=`.
106
+ Parse Server's S3FilesAdapter can return a freshly-signed URL in the
107
+ create response (not only on read); the save writer previously
108
+ assigned it verbatim to `@url` — and baked the signature query
109
+ string into `@name` via `File.basename` when the response omitted a
110
+ name — bypassing the `@url`-is-always-canonical invariant and the
111
+ `signed_url_policy = :raise` guard. The save writer now strips and
112
+ stashes like every other writer, derives any fallback name from the
113
+ canonical URL, and honors strict mode. (`lib/parse/model/file.rb`)
114
+ - **CHANGED**: `Parse::File#inspect` no longer includes the full
115
+ `@url` string. Inspect output lands in exception messages, Rails
116
+ error pages, log captures, and every error reporter the app uses
117
+ (Sentry / Honeybadger / Rollbar / Bugsnag); defaulting to URL
118
+ emission is a future leak waiting to happen even after the
119
+ normalization guarantees `@url` is canonical. The new format
120
+ emits `@name`, `@mime_type`, `@contents` (presence), and
121
+ `@url=set|blank`. See the migration callout above for the
122
+ error-reporter payload shift.
123
+ (`lib/parse/model/file.rb`)
124
+ - **NEW**: `Parse::File::SignedUrlError` is raised when
125
+ `signed_url_policy = :raise` is set and an incoming URL carries
126
+ a signed-URL signature parameter. Apps that want strict-mode
127
+ enforcement no longer need to subclass or monkey-patch — flip
128
+ the policy and the SDK's normalization point does the work.
129
+ `Parse::File.url_signature_param?(url_string)` and the
130
+ `SIGNATURE_QUERY_PARAMS` constant remain public for caller-side
131
+ custom detection logic. (`lib/parse/model/file.rb`)
132
+ - **NEW**: `Parse::File.log_filter` returns a frozen `Regexp` that
133
+ matches any plain-text HTTP(S) URL carrying an unambiguously
134
+ AWS-style signed-URL parameter — SigV4 (`X-Amz-Signature`,
135
+ `X-Amz-Credential`, `X-Amz-Security-Token`, `X-Amz-Algorithm`,
136
+ `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`), legacy
137
+ SigV2 (`AWSAccessKeyId`), or CloudFront (`Key-Pair-Id`). Designed
138
+ to be plugged into `lograge` / `semantic_logger` / custom log
139
+ scrubbers so accidental `Rails.logger.info(file_url)` calls do
140
+ not leak read capabilities into log aggregators. Bare
141
+ `Signature=` / `Policy=` query params are intentionally NOT
142
+ matched on their own — they collide with too many unrelated app
143
+ conventions (webhook signatures, privacy_policy form fields);
144
+ CloudFront URLs always carry `Key-Pair-Id` alongside `Signature`
145
+ / `Policy`, which IS matched. (`lib/parse/model/file.rb`)
146
+ - **NEW**: `Parse::File.log_filter_strict` returns the same
147
+ signature-detection regex but ALSO accepts the JSON-encoded
148
+ query separator (`\u0026` for `&`). Required for scrubbing
149
+ error-reporter event bodies (Sentry, Honeybadger, Rollbar,
150
+ Bugsnag) where the URL string has been JSON-encoded once before
151
+ reaching the scrubber and the literal `&` appears as `\u0026`.
152
+ The default `log_filter` would silently miss those — operators
153
+ shipping to a JSON-encoding error reporter should wire
154
+ `log_filter_strict` into the before-send hook. (`lib/parse/model/file.rb`)
155
+ - **NEW**: `Parse::File.filter_parameter_names` returns an
156
+ `Array<Regexp>` for `Rails.application.config.filter_parameters`.
157
+ Defaults to AWS-prefixed names only (`X-Amz-*`, `AWSAccessKeyId`,
158
+ `Key-Pair-Id`) so the list never over-redacts a Rails app's
159
+ privacy_policy / e-signature / policy_id form fields. Companion
160
+ `Parse::File.cloudfront_signed_param_names` returns the bare
161
+ `Signature` / `Policy` / `Expires` regexes as an opt-in
162
+ extension for CloudFront-heavy deployments that have confirmed
163
+ no app-side collision. (`lib/parse/model/file.rb`)
164
+ - **CHANGED**: `Parse::File#to_s` is deliberately left returning
165
+ `@url` unchanged — ERB templates and `<img src="<%= file %>">`
166
+ callers continue to work. Combined with the URL normalization
167
+ above, this makes the "@url is canonical" invariant structural
168
+ rather than convention-based — `to_s` cannot leak a signed URL
169
+ because such a URL is stripped before reaching `@url`.
170
+
171
+ #### `Parse::Lock` — public TTL-bounded mutual-exclusion primitive
172
+
173
+ - **NEW**: `Parse::Lock.acquire(key, ttl:, wait:, on_degraded:) { … }`
174
+ exposes the Redis-backed lock previously hidden inside
175
+ `first_or_create!` / `create_or_update!` as a first-class
176
+ primitive. TTL-bounded (1..30s, default 3s), with in-process
177
+ `Mutex` fallback when the configured cache is process-local
178
+ (Moneta `Memory` / `Null` / nil), and fails closed —
179
+ acquisition errors are caught, treated as "not acquired", and
180
+ surface as `Parse::Lock::TimeoutError` once the `wait:` budget
181
+ elapses. Block-form only (no token-based `try_acquire`); release
182
+ is automatic on normal return, exception, `throw`/`break`, or
183
+ any `ensure`-path exit. Keys are SHA-256-hashed before hitting
184
+ the store so sensitive identifiers (user IDs, request IDs,
185
+ webhook idempotency keys) don't appear verbatim in `KEYS *`
186
+ output (obfuscation, not authentication — see the YARD for the
187
+ guessable-input-space caveat). Documented use cases: bulk-import
188
+ dedup, cron-job singletons, external-API idempotency, anywhere
189
+ two processes might race the same logical operation. Built on
190
+ `Parse::LockBackend` (see below); namespace prefix
191
+ `parse-stack:lock:v1:` is distinct from `first_or_create!`'s
192
+ `parse-stack:foc:v1:`, so the two APIs cannot collide even on
193
+ literally-equal-named keys. (`lib/parse/lock.rb`)
194
+ - **NEW**: `Parse::Lock::TimeoutError` and
195
+ `Parse::Lock::UnavailableError` — namespaced under `Parse::Lock`
196
+ so the peer-not-base relationship to
197
+ `Parse::CreateLockTimeoutError` / `Parse::CreateLockUnavailableError`
198
+ is unambiguous in the name itself (a caller seeing
199
+ `Parse::Lock::TimeoutError` cannot reasonably read it as a base
200
+ of `Parse::CreateLockTimeoutError`). Both inherit from
201
+ `Parse::Error`; rescue chains targeting `Parse::Error` continue
202
+ to catch them. (`lib/parse/lock.rb`)
203
+ - **NEW**: `Parse::LockBackend` — `@api private` module hosting the
204
+ shared lock primitives (`lock_store`, `degraded_store?`,
205
+ `handle_degraded`, `try_acquire`, `release`, `poll_interval`,
206
+ `process_mutex`, `lock_secret_for`, `configured_secret`,
207
+ `auto_secret`, `warn_plain_sha_once`). Both `Parse::Lock` and
208
+ `Parse::CreateLock` consume the backend directly instead of one
209
+ reaching into the other's privates. The extraction eliminates
210
+ the `.send(:private)` coupling that the v5.1.0 round-2 review
211
+ called out as fragile — any future refactor of the SETNX
212
+ semantics, the degraded-detection heuristic, the in-process-Mutex
213
+ fallback registry, OR the HMAC secret resolution happens in
214
+ exactly one place. `Parse::CreateLock` retains only its
215
+ CreateLock-specific helpers (`clamp` for input range).
216
+ (`lib/parse/lock_backend.rb`, `lib/parse/model/core/create_lock.rb`)
217
+ - **NEW**: `Parse::Lock.acquire(secret:)` — HMAC keying option.
218
+ `:auto` (default) picks up the operator-configured
219
+ `PARSE_STACK_LOCK_SECRET` / `Parse.synchronize_create_secret`,
220
+ auto-derives a per-process secret for degraded stores, falls
221
+ back to plain SHA-256 with a one-time `[Parse::Lock:SECURITY]`
222
+ warn for cross-process stores without a configured secret. A
223
+ `String` value overrides the resolution per call; `nil`
224
+ explicitly opts out of HMAC (no warn — the opt-out is
225
+ deliberate). Closes the parity gap with `Parse::CreateLock`: an
226
+ operator setting one secret hardens both APIs without a second
227
+ config knob. Different secrets isolate locks on the same raw
228
+ key — useful for multi-tenant deployments sharing one Redis
229
+ where tenants must not block each other on coincidentally-equal
230
+ lock names. Real-Redis integration coverage in
231
+ `test/lib/parse/lock_redis_integration_test.rb` (Queue-gated to
232
+ eliminate sleep-based race flakes: HMAC-keyed entry shape,
233
+ plain-SHA opt-out, cross-process contention, fast-fail under
234
+ `wait: 0`, different-secret isolation, shared-secret-with-CreateLock
235
+ via env var, namespace separation from `first_or_create!`, atomic
236
+ compare-and-delete under a simulated lease-expiry race, and the
237
+ TTL-overrun warning). Explicit `secret:` kwarg
238
+ values are length-validated at the boundary —
239
+ `Parse::Lock::SECRET_MIN_BYTES` (= 16) is the floor for any
240
+ caller-supplied HMAC key. A `secret: "a"` misconfiguration is
241
+ refused with `ArgumentError` rather than silently degrading the
242
+ lock-pinning resistance HMAC keying is supposed to provide. The
243
+ operator-configured `PARSE_STACK_LOCK_SECRET` path is not
244
+ length-checked (different threat model — process-boot
245
+ configuration, not per-call argument). The `on_degraded:` YARD
246
+ now documents the asymmetric-degradation residual risk: if two
247
+ processes target the same Redis but disagree on degraded
248
+ detection, they derive different store keys for the same raw
249
+ key and silently fail to mutually exclude — mitigated by
250
+ uniform `Parse.synchronize_create_store` configuration or
251
+ `on_degraded: :raise`. (`lib/parse/lock.rb`)
252
+ - **NEW**: `Parse::Lock` and `Parse::LockBackend` are autoloaded —
253
+ `Parse::Lock.acquire(…)` works without an explicit
254
+ `require 'parse/lock'`. (`lib/parse/stack.rb`)
255
+ - **FIXED**: lock release is now an **atomic compare-and-delete**.
256
+ `Parse::Cache::Redis` gains raw-Redis `lock_acquire` (`SET NX EX`)
257
+ and `lock_release` (a Lua compare-and-delete), and `Parse::LockBackend`
258
+ routes both ends through them. The previous release read the owner
259
+ token and deleted in two separate commands; a holder whose lease
260
+ expired and was re-acquired by another holder between the two could
261
+ delete the new holder's live lock. The Lua CAD makes a stale owner's
262
+ release a guaranteed no-op. The raw path also uses plain-string keys
263
+ and values (bypassing Moneta's marshal transformers) so acquire and
264
+ release share one encoding and the keys are human-inspectable in
265
+ Redis. Non-Redis (raw-Moneta) stores keep the documented best-effort
266
+ GET-then-DEL bounded by the short TTL.
267
+ (`lib/parse/cache/redis.rb`, `lib/parse/lock_backend.rb`)
268
+ - **FIXED**: `Parse::Lock.acquire` no longer over-promises exactly-once
269
+ execution. The contract is now documented as mutual exclusion with a
270
+ DEADLINE: if the critical section outruns `ttl:`, the lease expires
271
+ mid-block and a second caller can acquire concurrently. The block now
272
+ receives its owner token (`acquire(key) { |token| … }`) for callers
273
+ who want to fence against a token-checking resource, and a
274
+ `[Parse::Lock]` warning is emitted on release when the section
275
+ overran its TTL (mutual exclusion was not guaranteed for the overrun
276
+ window). The misleading "two webhook deliveries can't double-charge"
277
+ example is replaced with an idempotency-required example.
278
+ (`lib/parse/lock.rb`)
279
+
280
+ #### LiveQuery — BREAKING: ACL-scoped by default; plus ergonomics (autoload, error context, signal-safe shutdown)
281
+
282
+ - **NEW**: `Parse::LiveQuery` is now autoloaded — `Parse::LiveQuery.configure { … }`
283
+ works without an explicit `require 'parse/live_query'`. The
284
+ autoload is purely a file-loading convenience and does NOT open
285
+ any network connection; a WebSocket only opens when
286
+ `Parse.live_query_enabled = true` AND a
287
+ `Parse::LiveQuery::Client` is instantiated (typically via
288
+ `Klass.subscribe { … }`). The opt-in toggle's security shape is
289
+ preserved. (`lib/parse/stack.rb`)
290
+ - **BREAKING**: LiveQuery connections are now **ACL-scoped by
291
+ default** — the connect frame no longer carries the master key
292
+ merely because one is configured. Parse Server resolves
293
+ master-key (ACL/CLP-bypass) authorization once, per CONNECTION,
294
+ from the connect frame (`_handleConnect` → `client.hasMasterKey`);
295
+ once set, EVERY subscription on that socket bypasses ACL/CLP and
296
+ returns every matching object regardless of its ACL. Prior
297
+ versions sent the master key on the connect frame whenever one was
298
+ present, so a `Parse.setup(master_key: …)` process silently
299
+ elevated session-token subscriptions the caller believed were
300
+ ACL-scoped. To get the old admin/event-tap behavior, build an
301
+ explicit admin connection:
302
+ `Parse::LiveQuery::Client.new(use_master_key: true)` or
303
+ `Parse::LiveQuery.configure { |c| c.use_master_key = true }`.
304
+ Admin connections emit a one-time `[Parse::LiveQuery:SECURITY]`
305
+ warning at connect. For a process that needs both scoped and
306
+ admin streams, use two separate clients.
307
+ (`lib/parse/live_query/client.rb`,
308
+ `lib/parse/live_query/configuration.rb`)
309
+ - **NEW**: `Parse::LiveQuery::Client.new(use_master_key: true)`,
310
+ the `config.use_master_key` toggle, and the
311
+ `Client#use_master_key` / `Client#admin_connection?` predicates
312
+ make the admin (ACL-bypassing) posture explicit and inspectable.
313
+ `admin_connection?` is the single source of truth for "will this
314
+ socket bypass ACL/CLP" — true only when the opt-in is set AND a
315
+ usable master key is present.
316
+ (`lib/parse/live_query/client.rb`,
317
+ `lib/parse/live_query/configuration.rb`)
318
+ - **CHANGED**: `Query#subscribe` / `Klass.subscribe` / `Client#subscribe`
319
+ still accept `use_master_key:`, but it is now an **intent assertion**,
320
+ not a per-subscription wire credential. Parse Server has no
321
+ per-subscription master key, so the subscribe frame NEVER carries
322
+ `masterKey` (sending it was a no-op that put a privileged credential
323
+ on the wire for zero effect). The flag is satisfied only on an admin
324
+ connection (where the whole socket is already elevated); on a
325
+ non-admin connection, `use_master_key: true` emits a one-time
326
+ `[Parse::LiveQuery:SECURITY]` warning and the subscription stays
327
+ ACL-scoped. Passing a `session_token:` on an admin connection
328
+ likewise warns — those results are NOT scoped to that token.
329
+ (`lib/parse/model/core/querying.rb`, `lib/parse/query.rb`,
330
+ `lib/parse/live_query/client.rb`, `lib/parse/live_query/subscription.rb`)
331
+ - **FIXED**: `Parse::LiveQuery::Client#inspect` and
332
+ `Subscription#inspect` now redact credentials. The default `inspect`
333
+ dumped every instance variable, exposing `@master_key`, `@client_key`,
334
+ and per-subscription `@session_token` in plaintext anywhere an object
335
+ was rendered — a log line, a backtrace, a Rails error page, or an
336
+ APM/error reporter (Sentry / Honeybadger / Rollbar / Bugsnag). The
337
+ custom `inspect` emits only non-secret diagnostics (url, state,
338
+ `admin_connection`, subscription count, request id, class name) and
339
+ `[REDACTED]` for any secret, matching the redaction
340
+ `Configuration#to_h` already applied. (`lib/parse/live_query/client.rb`,
341
+ `lib/parse/live_query/subscription.rb`)
342
+ - **NEW**: `Klass.subscribe`, `Query#subscribe`, and
343
+ `Parse::LiveQuery::Client#subscribe` all accept an optional `&block`
344
+ yielded the freshly-constructed `Subscription` **before** the
345
+ subscribe frame is sent to the server, so callbacks registered
346
+ inside the block (`sub.on(:create) { … }`) are wired before any
347
+ server event can arrive on the request_id. Order matters and is
348
+ tested — yielding AFTER the wire send would race a fast server
349
+ response against the callback registration on a hot socket. The
350
+ capture-then-wire form (`sub = Post.subscribe(…); sub.on(…)`)
351
+ still works for callers that prefer it. Matches the Parse JS
352
+ client's block-form convention. **If the block raises, the
353
+ subscription is rolled back out of the client's internal
354
+ `@subscriptions` registry before the exception propagates** —
355
+ without the rollback, the next reconnect's `resubscribe_all`
356
+ would silently wire-send the ghost subscription to the server
357
+ (round-3 review finding). (`lib/parse/live_query/client.rb`,
358
+ `lib/parse/query.rb`, `lib/parse/model/core/querying.rb`)
359
+
360
+ ```ruby
361
+ Post.subscribe(where: { published: true }) do |sub|
362
+ sub.on(:create) { |obj| puts "new: #{obj.id}" }
363
+ sub.on(:update) { |obj, _prev| puts "updated: #{obj.id}" }
364
+ end
365
+ ```
366
+ - **CHANGED**: `Parse::LiveQuery::SubscriptionError` now carries
367
+ `request_id` and `class_name` as structured attributes, and the
368
+ `message` is auto-prefixed with `request_id=<n> class=<X>` when
369
+ the constructor receives either. `Subscription#fail!` promotes
370
+ String errors from the server (e.g. `"Permission denied (code: 101)"`)
371
+ to typed instances carrying both the request id and the class
372
+ the subscription targeted — a single-line log captures enough
373
+ operational context to debug a permission denial without
374
+ re-correlating the raw server string against the subscription
375
+ registry. Backwards compatible — bare `SubscriptionError.new("…")`
376
+ callers (no context) preserve the verbatim message.
377
+ (`lib/parse/live_query.rb`, `lib/parse/live_query/subscription.rb`)
378
+ - **NEW**: `Parse::LiveQuery.run_until_signal!(client:, signals:,
379
+ shutdown_timeout:, poll_interval:) { |client| … }` is a
380
+ signal-safe shutdown helper for long-running subscribe sessions
381
+ (rake-task-style consumers, `rake livequery:tail`, etc.). The
382
+ raw idiom — calling `client.unsubscribe` / `client.close` from
383
+ inside a `Signal.trap` block — raises `ThreadError: can't be
384
+ called from trap context` on macOS / MRI on platforms that
385
+ enforce `:signal_safe?`, because the trap context cannot
386
+ acquire the client's internal `Monitor`. This helper bundles
387
+ the safe pattern: install minimal trap handlers that only push
388
+ a sentinel onto a `Queue`, poll the sentinel from the main
389
+ thread, and run `client.shutdown(timeout:)` on the main thread
390
+ in an `ensure` block. Restores prior trap handlers on exit so
391
+ re-running the helper (in tests, or in a parent process that
392
+ traps SIGINT itself) does not leak our handler. Defaults to
393
+ trapping `INT` and `TERM`; configurable via `signals:`.
394
+ Yields the client to the block before the wait loop starts so
395
+ subscription setup is not racing the trap installation.
396
+ (`lib/parse/live_query.rb`)
397
+
398
+ #### MCP — `structuredContent` outputSchemas for 5 more tools
399
+
400
+ - **NEW**: `output_schema` declarations on five additional built-in
401
+ tools so the MCP dispatcher auto-mirrors their result Hash into
402
+ `structuredContent` per MCP 2025-06-18: `aggregate`,
403
+ `export_data`, `atlas_text_search`, `atlas_autocomplete`,
404
+ `atlas_faceted_search`. Each schema is `type: "object"` with
405
+ every nested `type: "array"` declaring `items:` (so OpenAI's
406
+ strict tool-list validation and MCP client outputSchema
407
+ validation both accept them), and uses `additionalProperties: true`
408
+ on result-row entries to remain honest about the open shape of
409
+ arbitrary `$project` / `$group` / `$lookup` output. Brings the
410
+ built-in MCP tool coverage to sixteen of the catalog;
411
+ `call_method` (structurally polymorphic per application return)
412
+ and `explain_query` (MongoDB-version-dependent shape) remain
413
+ text-only by design. End-to-end emission coverage in
414
+ `test/lib/parse/agent/mcp_dispatcher_test.rb` (five new
415
+ `test_builtin_<tool>_emits_structuredContent` cases drive each
416
+ tool's `tools/call` path through the dispatcher); static
417
+ validity coverage in `test/lib/parse/agent/tools_schema_validity_test.rb`
418
+ walks the new schemas with the existing JSON-Schema-object-root
419
+ and array-items-present invariants. Builds on the eleven
420
+ v5.0.0 tools (`count_objects`, `get_object`, `get_objects`,
421
+ `get_sample_objects`, `distinct`, `group_by`, `group_by_date`,
422
+ `list_tools`, `get_all_schemas`, `get_schema`, `query_class`).
423
+ (`lib/parse/agent/tools.rb`)
424
+
425
+ #### Caching — tenant-aware namespacing
426
+
427
+ - **NEW**: `Parse.with_cache_tenant(scope) { … }` sets an ambient
428
+ cache-tenant scope for the duration of the block;
429
+ `Parse.current_cache_tenant` reads it. When set, the
430
+ `Parse::Middleware::Caching` middleware composes the tenant
431
+ into the cache key as `<base-namespace>:T:<tenant>:…` so a
432
+ multi-tenant Parse application sharing one Redis (or any
433
+ Moneta-backed cache) gets per-tenant key isolation without
434
+ per-tenant `Parse::Client.new` plumbing. A SCAN-delete over
435
+ `<base-namespace>:T:<tenant>:*` evicts exactly one tenant
436
+ cleanly; the existing `<base-namespace>:*` SCAN still evicts
437
+ the whole app. The `T:` discriminator is unambiguously
438
+ distinguishable from session-token hex prefixes (32-char hex)
439
+ and `mk:`, so legacy cache entries written before this feature
440
+ cannot re-hydrate into a tenanted request and vice versa.
441
+ Fiber-local — composes safely with `async` and concurrent web
442
+ frameworks; restored on block exit even when the block raises,
443
+ even when the owning Thread is killed mid-block (`Thread#kill`
444
+ runs `ensure` clauses, which matters for Puma's recycled thread
445
+ pool). Scope set in Fiber A is NOT visible to a concurrently-
446
+ running Fiber B; scope set in Thread A is NOT visible to Thread
447
+ B or the main thread — both explicitly tested.
448
+ AS::N payload (`parse.cache.{hit,miss,store,delete,error}`)
449
+ carries `:cache_tenant` so subscribers can budget cache
450
+ performance per tenant. Strictly a key-namespacing mechanism —
451
+ no access-control semantics; tenant isolation at the data
452
+ layer is the job of `agent_tenant_scope` and ACL/CLP.
453
+ (`lib/parse/stack.rb`, `lib/parse/client/caching.rb`)
454
+
455
+ #### Image embedding — `embed_image` DSL + Voyage multimodal-3 (URL-only)
456
+
457
+ The setup order is **(1) `Parse::Embeddings.allowed_image_hosts = […]` →
458
+ (2) `Parse::Embeddings.trust_provider_url_fetch = "PROVIDER_EGRESS_VERIFIED"`
459
+ → (3) declare `embed_image` on the model**. Skipping the allowlist or
460
+ the sentinel raises a typed error from the validator at save time;
461
+ each error message tells the operator which prerequisite is missing.
462
+
463
+ - **NEW**: `Parse::Embeddings::Cohere#embed_image(sources, input_type:, allow_insecure:)`
464
+ routes image URLs through Cohere's `/v2/embed` multimodal endpoint
465
+ for the `embed-v4.0` model (1536 native dim, Matryoshka-capable;
466
+ shares vector space with the text-input path on the same model).
467
+ Wire shape uses OpenAI-style nested
468
+ `{ type: "image_url", image_url: { url: ... } }` content rows —
469
+ different from {Voyage#embed_image}'s flat-String form, identical
470
+ high-level SDK contract (caller passes `Array<String>` URLs).
471
+ Refuses v3 models (text-only) with `BadRequestError` before any
472
+ network call; guards oversized batches (>96 per Cohere docs);
473
+ validates every URL up-front via
474
+ `Parse::Embeddings.validate_image_url!`. Internal
475
+ `Cohere#post_embeddings` grows a `path:` kwarg so the text path
476
+ continues to use `/v1/embed` while images route to `/v2/embed`.
477
+ (`lib/parse/embeddings/cohere.rb`)
478
+ - **NEW**: `Parse::Embeddings::Voyage#embed_image(sources, input_type:, allow_insecure:)`
479
+ routes image URLs through Voyage's `/v1/multimodalembeddings`
480
+ endpoint for the `voyage-multimodal-3` model (1024-dim, shares
481
+ vector space with the text-input path that already shipped). The
482
+ SDK does NOT download image bytes — URL-only is the v5.1 path
483
+ (bytes-fetch with MIME-sniff + EXIF stripping is the v5.3 path).
484
+ Calling `embed_image` on a text-only model raises a clear
485
+ `BadRequestError` before any network call. The provider reports
486
+ `modalities == %i[text image]` for the multimodal model and
487
+ `[:text]` for text-only models. (`lib/parse/embeddings/voyage.rb`)
488
+ - **NEW**: `Parse::Embeddings.validate_image_url!(url, allow_insecure:)`
489
+ is the canonical URL validator used by every `embed_image` path.
490
+ Layered checks, ordered cheap-first: (1) sentinel-gated
491
+ `trust_provider_url_fetch` opt-in must be set; (2) URL parses as
492
+ `https://` (or `http://` with `allow_insecure: true`, for local
493
+ dev only); (3) no userinfo; (4) host extracted via `uri.hostname`
494
+ so IPv6 literals are unbracketed and compare uniformly; (5) the
495
+ host is not an obfuscated IP form (`0x7f.0.0.1`, `127.1`,
496
+ `2130706433` — all rejected with `:host_blocked` BEFORE reaching
497
+ the resolver to keep operator logs honest about the failure mode);
498
+ (6) host matches `Parse::Embeddings.allowed_image_hosts` (string
499
+ match, no syscall — runs before the resolver hop so non-allowlisted
500
+ hosts can't amplify DNS traffic); (7) port in
501
+ `Parse::File.allowed_remote_ports`; (8) host resolves only to
502
+ addresses outside `Parse::File::BLOCKED_CIDRS` — delegated to
503
+ `Parse::File.assert_host_allowed!` so the SSRF mechanism is
504
+ shared, not parallelized. Returns the canonicalized URL String so
505
+ callers store/forward exactly what was validated. Failures raise
506
+ `Parse::Embeddings::InvalidImageURL` carrying a `:reason` Symbol
507
+ (`:scheme`, `:port`, `:userinfo`, `:host_blocked`,
508
+ `:host_not_allowlisted`, `:parse`); sentinel-off raises
509
+ `Parse::Embeddings::ConfirmationRequired`.
510
+ (`lib/parse/embeddings.rb`)
511
+ - **NEW**: `Parse::Embeddings.trust_provider_url_fetch=` sentinel-
512
+ gated opt-in for forwarding image URLs to embedding providers.
513
+ Assigning the exact frozen String `"PROVIDER_EGRESS_VERIFIED"`
514
+ unlocks; any other value (`true`, `"true"`, `1`, a non-matching
515
+ String) raises `Parse::Embeddings::ConfirmationRequired`. Mirrors
516
+ the `acl: :off` sentinel pattern — an operator unintentionally
517
+ flipping the gate via `ENV` interpolation is refused, making
518
+ accidental enablement impossible. Threat model: image-URL
519
+ forwarding hands an attacker-controlled URL (chat input, agent
520
+ tool argument, user-submitted document field) to a third-party
521
+ provider that will then issue an HTTP request from its own
522
+ network. Even with the CIDR / port / host allowlist enforced at
523
+ SDK-validation time, the provider's actual fetch happens later
524
+ (DNS-rebinding window) and can follow redirects the SDK never
525
+ saw — operators must consciously acknowledge the residual risk.
526
+ (`lib/parse/embeddings.rb`)
527
+ - **NEW**: `Parse::Embeddings.allowed_image_hosts=` allowlist
528
+ defining which CDN hostnames `validate_image_url!` will accept.
529
+ Entries beginning with `.` match suffixes (`.cloudfront.net`
530
+ matches `foo.cloudfront.net` and `cloudfront.net`); entries
531
+ without a leading `.` are exact. **Empty allowlist denies every
532
+ host** — opposite default from `Parse::File.allowed_remote_hosts`
533
+ (where empty means "any public host"). The asymmetry is
534
+ deliberate: image URLs that reach this validator typically
535
+ originate from attacker-controlled inputs, so opening the
536
+ surface requires an explicit operator declaration of which
537
+ CDNs are trusted. Frozen after assignment, case-insensitive
538
+ matching, reset by `Parse::Embeddings.reset!`.
539
+ (`lib/parse/embeddings.rb`)
540
+ - **NEW**: `embed_image source_field, into: :vector_property, input_type:
541
+ :search_document, digest_field: nil, allow_insecure: false` class
542
+ macro on `Parse::Object` subclasses. Mirrors `embed` but for
543
+ `:file`-typed sources. The source
544
+ property must be `:file` (text sources go through `embed`); the
545
+ target must be a declared `:vector` property with `provider:`
546
+ metadata. On `before_save`: extracts the file's URL, runs it
547
+ through `validate_image_url!`, and calls `Provider#embed_image`.
548
+ **Digest is the SHA-256 of the URL String, not the file bytes** —
549
+ replacing the `Parse::File` with one pointing at a different URL
550
+ re-embeds; resaving the same URL is a no-op (zero provider calls).
551
+ Cloud-stored Parse files have stable URLs unless overwritten, so
552
+ this matches typical upload behavior. If you mutate bytes at the
553
+ same URL (PUT-replace on S3 without renaming), null the digest
554
+ field to force re-embed. Reuses the existing `EmbedManaged` writer
555
+ guard, before_save registration, and protected-field semantics —
556
+ direct assignment to the managed vector raises `ProtectedFieldError`
557
+ as with text `embed`. (`lib/parse/model/core/embed_managed.rb`)
558
+ - **NEW**: `Parse::Core::EmbedManaged::EmbedDirective` gains
559
+ `modality:` (`nil`/`:text` for `embed`, `:image` for `embed_image`)
560
+ and `allow_insecure:` fields. `recompute_embedding!` dispatches on
561
+ modality, calling either `embed_text` or `embed_image`. The
562
+ source-input builder splits into `build_source_text` (existing,
563
+ concatenates text fields) and the new image-URL path (extracts
564
+ `file.url` and returns it raw — validation runs once, inside the
565
+ provider's `embed_image` call, to avoid double-resolving every
566
+ URL through DNS). Backwards compatible — every existing `embed`
567
+ directive continues to use the text path with no behavior change.
568
+ (`lib/parse/model/core/embed_managed.rb`)
569
+ - **CHANGED**: Base `Parse::Embeddings::Provider#embed_image` signature is
570
+ now `(sources, input_type:, allow_insecure: false, **opts)`.
571
+ `allow_insecure:` is documented as a contract kwarg —
572
+ `EmbedManaged.call_provider` unconditionally forwards it from the
573
+ directive, so future provider overrides must accept it (explicitly
574
+ or via `**opts`) or the managed-embedding save path will raise
575
+ `ArgumentError: unknown keyword`. Existing `Voyage#embed_image`
576
+ already accepts `allow_insecure:` explicitly. No other built-in
577
+ provider overrides `embed_image` yet, so this is a forward-compat
578
+ contract, not a breaking change. (`lib/parse/embeddings/provider.rb`)
579
+ - **NEW**: Voyage `embed_image` refuses oversized batches
580
+ (`sources.length > @embed_batch_size`, default 128) before any
581
+ validation or network call, with a clear "split and retry" error.
582
+ The text path goes through `embed_text_batched` which chunks
583
+ automatically; the image path has no chunker in v5.1, so a
584
+ direct-API caller passing 200 URLs gets a typed error instead of
585
+ a silent 400 from Voyage. (`lib/parse/embeddings/voyage.rb`)
586
+ - **NEW**: Integration coverage for the image-embedding save
587
+ round-trip — `test/lib/parse/embed_managed_image_integration_test.rb`
588
+ exercises Parse::File upload to the Docker Parse Server, the
589
+ before_save → validate → provider → vector-persist path, idempotent
590
+ no-op on unchanged URL, re-embed on file reassignment with a
591
+ different URL, the writer guard against a live server, and clean
592
+ save abort (no half-written record) when the sentinel is unset
593
+ or the URL is not in the allowlist. Unit coverage in
594
+ `test/lib/parse/embeddings_image_url_validation_test.rb` (36
595
+ cases: sentinel gate, allowlist semantics, every validator
596
+ failure mode including obfuscated-IP forms and the
597
+ allowlist-before-resolve ordering),
598
+ `test/lib/parse/embeddings_voyage_image_test.rb` (15 cases:
599
+ multimodal-model gating, wire envelope, canonicalized-URL
600
+ forwarding, allow_insecure precedence, batch-size guard),
601
+ `test/lib/parse/embeddings_cohere_image_test.rb` (16 cases:
602
+ parallel coverage for Cohere `embed-v4.0` plus the nested
603
+ `image_url: { url: }` envelope assertion, `/v2/embed` endpoint
604
+ routing, and an AS::N billed-input-tokens passthrough),
605
+ `test/lib/parse/embed_managed_image_test.rb` (24 cases:
606
+ declaration validation including `:file`-only source check,
607
+ digest semantics, writer guard, security wiring,
608
+ `embed` + `embed_image` co-declaration on the same record).
609
+
610
+ #### Client setup fixes — `Parse.setup` and `live_query_url`
611
+
612
+ - **FIXED**: `Parse.setup` (the module-level helper) silently no-op'd on every
613
+ call after the first. The implementation routed through `Parse::Client.new`,
614
+ whose constructor registers itself with `Parse::Client.clients[:default] ||= self`
615
+ — so once a default was set, subsequent `Parse.setup` invocations built a
616
+ new client, ran all the Faraday and LiveQuery configuration, and then
617
+ threw the result away because `||=` would not overwrite. The class-level
618
+ `Parse::Client.setup` uses `=` and did overwrite, so the two entry points
619
+ behaved differently despite being documented as equivalent. `Parse.setup`
620
+ now delegates to `Parse::Client.setup`, so re-configuring the default
621
+ client (Rake tasks that need to point at a prod URL after a development
622
+ initializer ran, multi-tenant boot, test isolation) works without
623
+ manually clearing `Parse::Client.clients[:default]` first. The `||=`
624
+ guard in `Parse::Client#initialize` is preserved so ad-hoc
625
+ `Parse::Client.new(...)` for secondary clients still does not hijack
626
+ the `:default` slot. (`lib/parse/client.rb`)
627
+ - **FIXED**: Passing `live_query_url:` (or any `live_query: {...}` options)
628
+ to `Parse.setup` / `Parse::Client.new` raised
629
+ `ArgumentError: wrong number of arguments (given 1, expected 0)`.
630
+ `Parse::Client#configure_live_query` was calling
631
+ `Parse::LiveQuery.configure(url:, application_id:, client_key:, master_key:, **opts)`
632
+ with keyword arguments, but `Parse::LiveQuery.configure` takes no
633
+ arguments and only yields a configuration block. The configuration is
634
+ now applied through the block form, assigning each option via the
635
+ `Parse::LiveQuery::Configuration` setters. Boot-time LiveQuery
636
+ configuration via `Parse.setup(live_query_url: ...)` now matches the
637
+ documented behavior. (`lib/parse/client.rb`)
638
+ - **FIXED**: `live_query_url:` (top-level) now correctly wins over
639
+ `live_query: { url: ... }` when both are passed. The first pass of
640
+ the block-form rewrite iterated `live_query_opts` after applying the
641
+ resolved URL, so the loop would re-write `config.url` from the hash
642
+ and silently invert the documented precedence. The hash's `:url`
643
+ key is now skipped in the loop and the resolved URL is applied last.
644
+ (`lib/parse/client.rb`)
645
+ - **NEW**: `Parse::Client#configure_live_query` now refuses an explicit
646
+ `ws://` URL against a non-loopback host unless
647
+ `live_query: { allow_insecure: true }` is also passed. The downstream
648
+ `Parse::LiveQuery::Client#derive_websocket_url` path already enforced
649
+ this for URLs derived from a Parse Server `http://` URL, but an
650
+ explicit `live_query: { url: "ws://prod-host" }` (or top-level
651
+ `live_query_url: "ws://prod-host"` / `PARSE_LIVE_QUERY_URL=ws://...`)
652
+ bypassed the check. The connect frame carries the master key and any
653
+ session token in cleartext on a non-TLS socket, so the explicit-URL
654
+ path now applies the same guard with the same `LOOPBACK_HOSTS`
655
+ exemption (`localhost`, `127.0.0.1`, `::1`, `[::1]`, `0.0.0.0`) and
656
+ the same `allow_insecure` escape hatch. (`lib/parse/client.rb`)
657
+ - **NEW**: `Parse::Client#configure_live_query` now warns on unknown
658
+ `live_query: { ... }` keys instead of silently dropping them. The
659
+ pre-fix kwargs form raised `ArgumentError: unknown keyword` on a
660
+ typo, so e.g. `live_query: { ssl_min_versoin: :TLSv1_3 }` would have
661
+ failed loudly; the block-form rewrite silently dropped them, leaving
662
+ the operator's intent invisible. The warning enumerates the unknown
663
+ keys and lists the valid setter surface; the call still proceeds so
664
+ this is a soft failure, not a hard one. (`lib/parse/client.rb`)
665
+
666
+ #### `Parse::Installation` and `Parse::User` — `user` pointer association
667
+
668
+ - **NEW**: `Parse::Installation` now declares `belongs_to :user`,
669
+ exposing the `user` pointer that Parse Server populates on
670
+ `_Installation` when the row is created or updated by an
671
+ authenticated client. The association is purely ergonomics — read
672
+ `installation.user` to find which user a device is currently signed
673
+ in as, write `installation.user = user; installation.save` from a
674
+ master-key context for targeted push grouping. The YARD prose calls
675
+ out the existing caveat from the class-level CLP notes: the `user`
676
+ pointer is not a reliable owner identity (devices outlive sessions
677
+ and can change users), so it should not be used for ACL or CLP
678
+ scoping. (`lib/parse/model/classes/installation.rb`)
679
+ - **NEW**: `Parse::User` now declares `has_many :installations, as: :installation`
680
+ as the query-form symmetric association. Each access issues a
681
+ `find` against `_Installation` for `where(user: self)`. Because
682
+ Parse Server hardcodes `_Installation` `find` to master-key-only at
683
+ the REST layer, this association only returns rows under a
684
+ master-key client; sessioned / sessionless clients get an empty
685
+ array (or fail closed under scoped agents). Useful for targeted
686
+ push — finding every device a user is signed into. The YARD
687
+ documents both the master-key requirement and the owner-identity
688
+ caveat. (`lib/parse/model/classes/user.rb`)
689
+
690
+ #### `_User` field-visibility DSL — `master_only_fields` and `self_visible_fields`
691
+
692
+ - **NEW**: `Parse::User.master_only_fields(*fields)` declares fields that
693
+ should be hidden from query/get responses for every non-master caller,
694
+ including the owning user themselves. Useful for admin-only metadata
695
+ living on `_User` (e.g. internal scoring, moderation notes). Expands
696
+ internally to a `protect_fields "*"` entry. Effective only when Parse
697
+ Server is started with `protectedFieldsOwnerExempt: false` — the
698
+ default `true` exempts the owning user from every `protectedFields`
699
+ rule on `_User` and would silently negate the protection. The SDK
700
+ documents the dependency on the helper's YARD and surfaces it in the
701
+ one-time advisory described below. (`lib/parse/model/classes/user.rb`)
702
+ - **NEW**: `Parse::User.self_visible_fields(*fields, via: :self)`
703
+ declares fields that should be hidden from public, role, and other-
704
+ user callers but visible to the owning user on their own row.
705
+ Expands internally to a `protect_fields "*"` plus a
706
+ `protect_fields "userField:<via>"` pair whose intersection resolves
707
+ to "owner sees the field, nobody else does". Requires (a) Parse
708
+ Server option `protectedFieldsOwnerExempt: false` and (b) a
709
+ self-pointer field on `_User` (default field name `:self`) populated
710
+ by a `beforeSave('_User')` Cloud Code trigger. The SDK cannot
711
+ install either — both are server-side configuration — and the helper
712
+ documents both prerequisites inline. (`lib/parse/model/classes/user.rb`)
713
+ - **NEW**: One-time process-scoped advisories when the new DSL helpers
714
+ or raw `protect_fields` are called on `Parse::User`:
715
+ - First invocation of `master_only_fields` or `self_visible_fields`
716
+ surfaces the `protectedFieldsOwnerExempt: false` server-option
717
+ prerequisite. With the default `true`, the owning user is silently
718
+ exempted from every `protectedFields` rule on `_User`, so a field
719
+ declared master-only would still be visible to the user themselves
720
+ on their own row. The SDK cannot introspect Parse Server's startup
721
+ options, so the advisory fires at class declaration so it's
722
+ surfaceable before deploy.
723
+ - First invocation of `self_visible_fields` also surfaces the
724
+ self-pointer prerequisite: the `via:` field has to exist on `_User`
725
+ and be populated by a `beforeSave('_User')` Cloud Code trigger,
726
+ AND pre-existing user rows need a one-shot backfill before the
727
+ `userField:<via>` group matches them.
728
+ - Direct calls to `protect_fields` on `Parse::User` outside the
729
+ helpers point the caller at `master_only_fields` /
730
+ `self_visible_fields` plus the same `protectedFieldsOwnerExempt`
731
+ reminder. Behavior is otherwise unchanged. The helpers themselves
732
+ set a class-level bypass flag so the raw-protect_fields advisory
733
+ does not double-fire. Internal SDK callers (e.g. the
734
+ `parse_reference` embedded-reference DSL auto-install in
735
+ `lib/parse/model/core/parse_reference.rb`) also bypass the
736
+ raw-protect_fields advisory so gem boot stays quiet on apps that
737
+ use embedded references on `_User`. (`lib/parse/model/classes/user.rb`,
738
+ `lib/parse/model/core/parse_reference.rb`)
739
+
740
+ #### `_Installation` CLP advisory
741
+
742
+ - **NEW**: One-time process-scoped advisory emitted from
743
+ `Parse::Installation` when any of `set_clp`, `set_class_access`,
744
+ `set_read_user_fields`, or `set_write_user_fields`
745
+ is invoked on the class. Parse Server hardcodes `find` and `delete`
746
+ on `_Installation` to master-key-only at the REST layer
747
+ (`SharedRest.js`), and gates `create` / `update` on the
748
+ `X-Parse-Installation-Id` header rather than CLP — so most CLP
749
+ changes on `_Installation` either do nothing or break the SDK's
750
+ device-registration flow. The advisory enumerates which operations
751
+ CLP actually controls on this class (`get`, `count`, `addField`,
752
+ `protectedFields`) and points the caller at the
753
+ `beforeSave('_Installation')` Cloud Code pattern for login-required
754
+ write policy. Behavior is otherwise unchanged. (`lib/parse/model/classes/installation.rb`)
755
+
756
+ #### Documentation
757
+
758
+ - **NEW**: `docs/acl_clp_guide.md` is the canonical reference for ACL,
759
+ CLP, `protectedFields`, role hierarchy, and field-guard write
760
+ protection across parse-stack-next. Covers the five enforcement
761
+ layers (CLP, ACL, `protectedFields`, field guards, master-key
762
+ bypass); the system-class CLP matrix (which classes actually honor
763
+ CLP versus the ones hardcoded master-key-only at the REST layer:
764
+ `_JobStatus`, `_PushStatus`, `_Hooks`, `_GlobalConfig`,
765
+ `_GraphQLConfig`, `_JobSchedule`, `_Audience`, `_Idempotency`,
766
+ `_Join:*`); the `_Installation` hardcoded asymmetry; the `_User`
767
+ field-visibility recipe with `protectedFieldsOwnerExempt` and the
768
+ self-pointer pattern; role hierarchy direction (the
769
+ `inherits_capabilities_from!` vs `add_child_role` distinction); the
770
+ field-guard modes and their webhook dependency; the REST-aggregate
771
+ vs `Parse::MongoDB.aggregate` enforcement asymmetry (REST aggregate
772
+ is master-key-only and enforces NEITHER CLP nor ACL nor
773
+ `protectedFields`); Atlas Search inheriting SDK-side enforcement
774
+ through the mongo-direct path; and a pitfalls section. (`docs/acl_clp_guide.md`)
775
+ - **CHANGED**: `docs/client_sdk_guide.md` §4 now opens with a banner
776
+ pointing readers at the new comprehensive ACL/CLP guide. Sections
777
+ added in this release for `_Installation` CLP semantics (§6.3),
778
+ the full system-class CLP matrix (§6.5), and the `_User` field-
779
+ visibility recipe with the intersection-resolution table (§6.6)
780
+ remain in the SDK guide as a client-mode quickstart. (`docs/client_sdk_guide.md`)
781
+ - **CHANGED**: YARD `@note` on `Parse::JobStatus`, `Parse::PushStatus`,
782
+ `Parse::Audience`, and `Parse::JobSchedule` now states that the
783
+ class is hardcoded master-key-only at Parse Server's REST layer and
784
+ that CLP changes are ignored. YARD on `Parse::Session` documents the
785
+ non-master find auto-scoping to `user = <current user>`, so CLP
786
+ cannot grant cross-user session visibility. YARD on
787
+ `Parse::Installation` carries the full operation-by-operation CLP
788
+ effectiveness table. (`lib/parse/model/classes/job_status.rb`,
789
+ `lib/parse/model/classes/push_status.rb`,
790
+ `lib/parse/model/classes/audience.rb`,
791
+ `lib/parse/model/classes/job_schedule.rb`,
792
+ `lib/parse/model/classes/session.rb`,
793
+ `lib/parse/model/classes/installation.rb`)
794
+
3
795
  ### 5.0.1
4
796
 
5
797
  #### Redis cache wrapper compatibility with `Parse::CreateLock`