parse-stack-next 5.6.0 → 5.7.1

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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +219 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7ea05ce0688ca7c1df6a1077162478dfee52e33d9fac77c7480c5ca818c9eb8
4
- data.tar.gz: 71e61f7e70da94793dae9d741a3e5d29ffc7f9cd45f2adee6c96cd98b5ac2d24
3
+ metadata.gz: 7cb6a2c813f0a1b2d624852680651ed289f7aac282fa24e246b28d0ed9e69383
4
+ data.tar.gz: a3984ae30555af28eadfdc19fa936103bb252bad332b0ddb8ead163277ae4bde
5
5
  SHA512:
6
- metadata.gz: c723fbb9589ce57c7651fab142bed386f3a2070d39fc20887fc40b50f676ccb0041e9b758611e8b75d0a9202095de318ce2bbae35e41015be519209d9d2628dd
7
- data.tar.gz: dbbc09ee718630415da1ab329168fc1c10d5d9cf20ad09e4129b2518087231206ca84d7d5ecd5e1a27f66e2fb75d3704b856592fcd43c6d2f7711fa20c0dba1b
6
+ metadata.gz: d19428000255676b8a5f10ee0087bf3b6d21216c499f56d895d1ee50a646985a4297b725807b2b19878a8576415c8e1551471d34fa236e9bf90170e6e13abed5
7
+ data.tar.gz: 5db3159d3da214300df9e21760ae871f070f6a79d961fe9e082b5160dd0bd2c07cba5d64656791b961ab2c347e9ebf5151eb607fcf0a3768587a3c382476000e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,494 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.7.1
4
+
5
+ #### Cache-invalidation webhooks no longer break every application hook for the same trigger
6
+
7
+ - **FIXED**: `Parse::Cache::Invalidation` raised `NoMethodError: undefined
8
+ method 'guard'` on every `_User`, `_Role`, and `_Session` trigger it
9
+ registered, and the failure was not contained. A webhook handler block is
10
+ bound to the payload before it runs, so the bare `guard` / `bump_subject` /
11
+ `subject_id` calls in the handler bodies resolved against
12
+ `Parse::Webhooks::Payload`, which defines none of them. `guard`'s own
13
+ `rescue` never ran, because it lives inside the body that was never entered,
14
+ so the error escaped into the route dispatcher. The dispatcher folds a
15
+ trigger's handlers with `Array#map`, which abandons the collection on the
16
+ first raise, and these triggers install during `Parse.setup` and therefore
17
+ sit ahead of every application handler for the same trigger. One
18
+ unresolvable method name silently prevented an application's own
19
+ `after_save "_User"` hook from running at all, so any work downstream of it
20
+ stopped without a visible cause. The handlers now call the module on an
21
+ explicit receiver captured in a local, which is independent of whatever
22
+ `self` the dispatcher binds. Applications that set
23
+ `cache_invalidation_hooks: false` to work around this can remove it.
24
+ - **FIXED**: The invalidation tests dispatched handlers with a plain
25
+ `Proc#call`, which leaves `self` bound to the module the block closed over,
26
+ so every one of them passed against handlers that could not run in
27
+ production. They now dispatch through the real handler invocation and fail
28
+ against the broken code.
29
+
30
+ #### Whether one failing `after_*` handler stops the rest is now a setting
31
+
32
+ - **NEW**: `Parse::Webhooks.abort_after_callbacks_on_error` decides whether an
33
+ exception raised by one `after_*` handler prevents the remaining handlers for
34
+ that trigger from running. It defaults to `true`, which is the existing
35
+ behavior, so nothing changes on upgrade. Set it to `false` to isolate
36
+ handlers from each other: each one runs regardless of what an earlier one
37
+ raised, and the failure is reported with a warning and a
38
+ `parse.webhooks.handler_error` notification instead of being swallowed
39
+ silently. This was previously not a decision at all but a side effect of
40
+ folding handlers with `Array#map`, which abandons the collection on the
41
+ first raise. Registration order is not fully under an application's control,
42
+ since the SDK's own cache-invalidation triggers install during `Parse.setup`
43
+ and therefore sit ahead of handlers registered by application files loaded
44
+ later, so a single raising handler could silently prevent an application's
45
+ own hooks from running with nothing logged to say they had been skipped.
46
+ - **BEHAVIOR**: The setting governs only whether later handlers still run.
47
+ Neither mode reverts anything: an `after_*` trigger fires once the write has
48
+ already committed, so there is no version of this setting that can undo a
49
+ save. `before_*` dispatch is untouched, and only the accumulating,
50
+ non-rejectable triggers (`after_save`, `after_delete`, `after_logout`) can
51
+ hold more than one handler in the first place. A rejectable `before_*`
52
+ trigger must deny if any handler denies, so its raise must continue to
53
+ abort.
54
+
55
+ #### Failed transactions restore the object they rolled back
56
+
57
+ - **FIXED**: A failed `Parse::Object.transaction` left the in-memory object
58
+ holding its modified values. The rollback snapshotted `Parse::Object#attributes`
59
+ and restored it, but that method returns a schema map of field name to type
60
+ symbol rather than values, so nothing was ever restored. Property values
61
+ live in `@<field>` instance variables; those are now what the rollback
62
+ captures and restores, including values nested inside arrays and hashes,
63
+ relation operation queues, and properties whose ivar did not exist before
64
+ the transaction. State is captured when the object first enters the
65
+ transaction rather than at `batch.add`, so the documented pattern of
66
+ mutating an object and adding it afterwards rolls back correctly.
67
+ - **FIXED**: A failed transaction also left the object with broken change
68
+ tracking. Restoring the schema map defined `@attributes`, which is the
69
+ instance variable `ActiveModel::Dirty` keys its behavior on: once defined it
70
+ builds an `AttributeMutationTracker` over that hash instead of the
71
+ `ForcedMutationTracker` a `Parse::Object` needs, and `clear_changes!` then
72
+ called `forgetting_assignment` on the `[key, value]` pairs `Hash#map`
73
+ yields. Both call sites rescue and warn, so every rollback quietly
74
+ downgraded the object rather than failing. The rollback no longer defines
75
+ `@attributes`.
76
+ - **FIXED**: The mongo-direct role-graph integration test gated on
77
+ `ANALYTICS_DATABASE_URI`, a production variable name the test stack never
78
+ sets, so both of its traversal assertions skipped on every run while the
79
+ per-file reporter still reported the file as passing. It now reads
80
+ `PARSE_TEST_MONGO_URI` like every other mongo-direct integration file and
81
+ still honors `ANALYTICS_DATABASE_URI` as an override.
82
+
83
+ ### 5.7.0
84
+
85
+ #### Cache keys move into a reserved, app-scoped keyspace
86
+
87
+ - **NEW**: `Parse::Cache::Keyspace` owns the physical layout of the response,
88
+ identity, and role-cache keys the SDK writes to a shared cache backend along
89
+ with the glob patterns that clear them again, so key generation and eviction
90
+ can no longer drift apart. Keys are laid out as
91
+ `parse-stack:v1:<app_scope>[:<namespace>]:<family>[:T:<tenant>]:<rest>`, with
92
+ `cache`, `idn`, and `role` as the families. `app_scope` is a digest of the
93
+ application id and the server URL rather than the raw values, so two apps
94
+ sharing one Redis no longer collide when neither sets a `cache_namespace:`,
95
+ and an application id carrying a glob metacharacter cannot silently widen a
96
+ SCAN pattern. Enable it with `cache_keyspace: true` on `Parse.setup`. Without
97
+ that option the previous key shape and behavior are preserved exactly, so the
98
+ upgrade is inert until an operator opts in.
99
+ - **FIXED**: `Parse::Client#clear_cache!` no longer reaches `FLUSHDB` **when a
100
+ keyspace is configured**. The wrapper fell through to a full flush whenever no
101
+ namespace was set on it, which is the default, and the caching middleware
102
+ could hold a `cache_namespace:` the wrapper knew nothing about. On a shared
103
+ Redis that destroyed co-tenant data, and it deleted the SDK's own
104
+ `parse-stack:foc:v1:*` create-locks, removing `first_or_create!` mutual
105
+ exclusion for any lock held at the time with no error anywhere. With
106
+ `cache_keyspace: true`, every clear is a scoped SCAN inside the client's own
107
+ keys and can only ever delete a subset of them, and `scope:` narrows within
108
+ that keyspace rather than widening past it. `flush_db!` remains the explicit
109
+ opt-in for a full flush.
110
+
111
+ **Without `cache_keyspace: true` the old behavior is unchanged**, including
112
+ the `FLUSHDB` fallback when no namespace is set, and a plain Moneta store
113
+ passed as `cache:` is still cleared in full because it has no notion of key
114
+ scoping. Opting in is what fixes it. This is stated plainly because a reader
115
+ who upgrades and changes nothing else is still exposed.
116
+ - **NEW**: The keyspace-bound `Parse::Cache::ScopedView` exposed as
117
+ `client.sdk_cache` accepts `family:` and `tenant:` on `#clear`, and
118
+ `#delete_matching(pattern)` evicts by glob. A pattern outside the view's own
119
+ keyspace is a no-op rather than an unscoped scan, so the narrower API cannot
120
+ become a back door to the blast radius the keyspace exists to close.
121
+ - **CHANGED**: Scoped eviction issues `UNLINK` rather than `DEL` when the
122
+ client exposes it, so reclaiming a large eviction runs on a Redis background
123
+ thread instead of stalling the server, falling back to `DEL` on older
124
+ clients. Each eviction emits a `parse.cache.evict`
125
+ `ActiveSupport::Notifications` event carrying `pattern_digest`, `deleted`,
126
+ and `duration_ms`, so an operator can see how much a clear actually removed
127
+ and how long it took. The pattern is digested rather than logged because it
128
+ embeds a URL digest and a cache tenant.
129
+ - **CHANGED**: `Parse::Cache::Redis` refuses a Moneta `prefix:` option. It
130
+ rewrites the physical key layout underneath the wrapper, which would break
131
+ every SCAN pattern the class builds and quietly restore the unscoped clearing
132
+ the keyspace exists to prevent. Use `cache_namespace:` instead.
133
+
134
+ #### The response cache's auth separation is now enforced by construction
135
+
136
+ - **IMPROVED**: The auth discriminator on a response-cache key is now
137
+ structural rather than incidental. A master-key request bypasses ACL, CLP, and
138
+ `protectedFields`, so for the same URL it returns a strictly fuller body than a
139
+ session-token request, and two session-token requests can differ from each
140
+ other through `protectedFields` entity rules and row ACLs. Previous versions
141
+ already separated these, prefixing the key with `mk:` or a token digest, so
142
+ this is not a fix for a cache that crossed those boundaries. What changes is
143
+ that the separation can no longer be lost by accident:
144
+ `Parse::Cache::Keyspace#cache_key` has no default for `auth:` and raises
145
+ rather than building a key without one, and the generic key builder refuses
146
+ the response-cache family outright. A raw session token is still never
147
+ accepted as a key segment, only a truncated digest.
148
+ - **FIXED**: A non-GET write now invalidates the resource for every caller. The
149
+ previous invalidation could name only the anonymous variant, the master-key
150
+ variant, and the caller's own, and had no way to enumerate the entries of
151
+ sessions the process had never seen, so a write by one user left every other
152
+ user reading a stale copy until the TTL expired. Every auth variant of one
153
+ resource now shares a key prefix, so a scan-capable store evicts all of them
154
+ with a single pattern. A GET miss still clears only the siblings it can name,
155
+ since evicting resource-wide there would destroy other sessions' valid
156
+ entries on every cache miss.
157
+ - **CHANGED**: Invalidation also deletes the pre-keyspace form of a key, so a
158
+ rolling deploy does not leave old workers serving entries that a new worker's
159
+ write should have killed. Pass `cache_delete_legacy_variants: false` to stop
160
+ once every old worker is drained.
161
+
162
+ #### Identity and role caching share one backend across processes
163
+
164
+ - **NEW**: `Parse::Cache::ScopedView#identity` and `#roles` return
165
+ `Parse::Cache::SubCache` planes shaped for a client's
166
+ `Parse::Authorization::Context#identity_cache` and `#role_cache` slots. The
167
+ keyspace-bound view is exposed as `client.sdk_cache`; installing its planes
168
+ replaces the default per-process memory caches with a shared backend, so
169
+ every Puma worker and every dyno resolves a session token or a role closure
170
+ against the same view instead of each holding its own. Each plane writes
171
+ inside its own keyspace family, so clearing one can reach neither the other
172
+ nor the response cache.
173
+ - **NEW**: Each plane carries a per-subject generation counter and a plane-wide
174
+ epoch, both for invalidating entries that cannot be named. Identity entries
175
+ are keyed by session token and no reverse map from user id exists, so a
176
+ `_User` write cannot enumerate them. Bumping that user's generation
177
+ invalidates all of them in constant time, including tokens the process has
178
+ never resolved, and without a master-key `_Session` query. The epoch answers
179
+ the different question of whether an entry written before a given moment is
180
+ stale, which is what judging a foreign cache entry requires. It never moves
181
+ backwards, so clock skew between workers cannot re-admit an entry a previous
182
+ invalidation had rejected.
183
+
184
+ #### Role and session invalidation no longer depends on application discipline
185
+
186
+ - **NEW**: `Parse::Cache::Invalidation` registers the webhook triggers that
187
+ keep the identity and role planes honest: `after_save` and `after_delete` on
188
+ `_Role`, `after_save` and `after_delete` on `_User`, and `after_logout` on
189
+ `_Session`. It installs alongside the keyspace and is disabled with
190
+ `cache_invalidation_hooks: false`. The previous contract asked applications
191
+ to call `Parse::AtlasSearch::Session.invalidate` and `invalidate_user_roles`
192
+ from their own logout and role-mutation paths. That depended on every
193
+ application remembering, and it missed role changes made by any other client,
194
+ including a mobile SDK, the dashboard, and Node cloud code. The triggers
195
+ cover writes from every source Parse Server sees. TTL remains the backstop,
196
+ since the triggers require a webhook endpoint Parse Server can reach and
197
+ hooks registered against it: this is TTL and hooks, not TTL or hooks.
198
+ - **FIXED**: Registering a webhook handler replaced any handler already
199
+ registered for the same trigger instead of composing with it, for every
200
+ trigger except `after_save` and `after_delete`. A second `after_logout`
201
+ registration silently discarded the first, with file load order deciding the
202
+ winner and nothing warning about it. Non-rejectable `after_*` triggers now
203
+ accumulate handlers the way `after_save` always has. Rejectable `before_*`
204
+ triggers deliberately keep replacing: a composite of those must deny if any
205
+ handler denies, and folding the results with `.last` would discard an earlier
206
+ rejection.
207
+
208
+ #### Optional read of Parse Server's own role cache
209
+
210
+ - **NEW**: `Parse::Cache::UpstreamRoles` reads the `<appId>:role:<userId>`
211
+ closure Parse Server writes for itself, so a caller holding a trusted user
212
+ id, most usefully from a webhook payload, can skip the role-graph walk by
213
+ calling `client.sdk_cache.upstream_roles.roles_for`. Attach it by passing
214
+ `parse_cache_url:` to `Parse::Cache::Redis`. Without that option nothing
215
+ upstream is read.
216
+ - **NEW**: Role resolution itself does not consume the upstream value in this
217
+ release. `Parse::AtlasSearch::Session` always computes its own closure, and
218
+ the only built-in integration is `compare_upstream_roles`, which reads the
219
+ upstream entry purely to emit a `parse.cache.role_compare` event carrying
220
+ the size of each set and their symmetric difference. Nothing about the ACL
221
+ decision changes. This is deliberate: the upstream value becomes an
222
+ authorization input the moment it is consumed, so it stays observable-only
223
+ until the two closures have been reconciled against real traffic. The
224
+ comparison is inert unless both the switch and a reader are set.
225
+ - **NEW**: The attachment is strictly read-only. The SDK never writes that
226
+ keyspace, because its own closure is depth-capped while Parse Server's is
227
+ not, so injecting a strict subset into a cache the server reads back as
228
+ authoritative would under-permission users in windows that are close to
229
+ undiagnosable.
230
+ - **NEW**: Every failure mode degrades to a miss so the caller recomputes the
231
+ closure, and none of them fails open. The decoded value must be a JSON array
232
+ of `role:`-prefixed names within the configured count and length caps. An
233
+ entry whose remaining PTTL cannot be read, is negative, or exceeds the
234
+ configured ceiling is rejected, because an entry whose age cannot be derived
235
+ is not one to trust as an authorization input. An entry written before the
236
+ SDK's last role invalidation is rejected by the plane epoch, because Parse
237
+ Server does not clear its own role cache on a `_Role` delete. The reader
238
+ needs only `+get` and `+pttl`, so the credential can be restricted to the
239
+ role keyspace.
240
+ - **NEW**: `Parse::Cache::Redis#verify_upstream_isolation!` probes whether the
241
+ two endpoints resolve to the same Redis database, first by scanning the
242
+ SDK's own database for a key shaped like one Parse Server would have
243
+ written, then, if that finds nothing, by writing a random sentinel to the
244
+ SDK's database and asking the upstream connection to read it back. The scan
245
+ alone can only ever prove sharing: an empty result is equally consistent
246
+ with a separate database and with a shared one on which Parse Server has
247
+ not yet cached a role closure, which is the state of every freshly deployed
248
+ stack. Only the sentinel establishes the negative. The method returns
249
+ `true` for established isolation, `false` for established sharing, and
250
+ `:unknown` when neither could be shown, which is what a credential
251
+ restricted to `~<appId>:role:*` produces: the sentinel read is denied, and
252
+ a denial says nothing about which database denied it. `:unknown` is truthy,
253
+ so callers branching on truthiness are unaffected. Comparing URLs would be
254
+ defeated by `localhost` against `127.0.0.1`, by CNAMEs, by Sentinel and
255
+ Cluster topologies, and by a database selected outside the URL. A shared
256
+ database is a real hazard: on Parse Server 9.10.0 and earlier a `_Role` write
257
+ clears the cache with `FLUSHDB`, which takes the SDK's cached responses and
258
+ its `first_or_create!` create-locks with it. See
259
+ https://github.com/parse-community/parse-server/issues/10617. The probe warns
260
+ rather than refusing to boot, since the hazard disappears entirely on a
261
+ server carrying the scoped-clear fix.
262
+
263
+ #### Role graph queries now accept the public API's default depth
264
+
265
+ - **CHANGED**: Raised the MongoDB role-graph query default and hard cap from 6
266
+ to 10, matching the existing `Parse::Role.all_for_user` default. A ceiling
267
+ below that default made the opt-in MongoDB fast path raise `ArgumentError`
268
+ for any caller who did not pass an explicit `max_depth:`. The existing
269
+ query-time budget continues to bound traversal work.
270
+
271
+ #### Users and roles can inspect effective object access
272
+
273
+ - **NEW**: `Parse::User` and `Parse::Role` expose `can_read?`, `can_write?`,
274
+ and `can_delete?` predicates backed by the new `Parse::Access.check` policy
275
+ preflight. Each check combines the target ACL with its class's `get`,
276
+ `update`, or `delete` CLP; delete uses the ACL write grant. Direct and
277
+ inherited user/role grants are supported, as are Parse Server's
278
+ `pointerFields`, `readUserFields`, and `writeUserFields` branch semantics.
279
+ `Parse::Access::Decision` and the instance `access_decision` /
280
+ `access_decisions` helpers expose `allowed`, `denied`, or `unknown` results;
281
+ the boolean predicates accept only a definite allow and otherwise fail
282
+ closed. Full rows with no ACL retain Parse Server's public default, while
283
+ pointers, partial rows, unresolved schema/role evidence, and unsupported
284
+ system-class rules remain unknown. `_User` reads and mutations honor Parse
285
+ Server's self-access rules, and role-only checks cannot claim a concrete
286
+ member's pointer or `_User` self permission. CLP cache entries are isolated
287
+ by Parse application so identically named classes cannot leak policy across
288
+ clients. These helpers are advisory: the eventual Parse Server request is
289
+ still authoritative.
290
+
291
+ #### Test infrastructure
292
+
293
+ - **CHANGED**: The integration test stack pins Parse Server 9.10.0, up from
294
+ 9.9.0.
295
+ - **CHANGED**: The integration stack now backs Parse Server's own session,
296
+ user, and role caches with Redis instead of its in-process adapter, so the
297
+ `<appId>:role:<userId>` entries the upstream reader consumes are observable
298
+ from outside the container. It occupies database 1 while the SDK's cache and
299
+ create-locks stay on database 0, and the adapter refuses to start on database
300
+ 0. Leaving the URL unset keeps the in-process adapter and the previous
301
+ behavior.
302
+
303
+ #### Session-token and role resolution move off Atlas Search and onto the client
304
+
305
+ - **NEW**: `Parse::Authorization` is the new owner of session-token resolution
306
+ and role-closure expansion. `client.authorization` returns a
307
+ `Parse::Authorization::Context`, one per `Parse::Client` and never shared.
308
+ Session-token resolution and role-closure expansion were originally written
309
+ inside `Parse::AtlasSearch::Session`, because `$search` was the first
310
+ feature to run aggregations straight against MongoDB and therefore the
311
+ first to enforce ACLs itself. Everything since reached back through it:
312
+ `Parse::ACLScope` called into the Atlas Search namespace, and
313
+ `Parse::MongoDB.aggregate` calls `Parse::ACLScope`, so
314
+ `Parse::Query#results_direct` on a plain query with no `$search` anywhere
315
+ in it depended on Atlas Search to decide who the caller was.
316
+ `Parse::Authorization` is now the sole owner; Atlas Search is one consumer
317
+ of it alongside every other mongo-direct path.
318
+ - **FIXED**: Two `Parse::Client` instances addressing two different Parse
319
+ applications no longer share one identity cache and one role cache. The
320
+ previous caches, TTLs, and resolver were module-level globals reachable
321
+ only through `Parse.client`, so a session token minted by a secondary
322
+ application's Parse Server could be validated against the default
323
+ application's `/users/me` call and its cached role closures. Each
324
+ `Parse::Authorization::Context` now holds a back-reference to the one
325
+ client it authorizes for and resolves exclusively through it.
326
+ - **NEW**: `Parse::Authorization.configure(identity_cache:, role_cache:,
327
+ identity_cache_ttl:, role_cache_ttl:, upstream_role_reader:,
328
+ compare_upstream_roles:)` configures the default client's context, as a
329
+ boundary convenience matching the existing single-application shorthand
330
+ `Parse::AtlasSearch.search(..., client: Parse.client)`. It is not the
331
+ source of truth: configure a secondary application with
332
+ `other_client.authorization.configure(...)` directly.
333
+ `Parse::Authorization.resolve(session_token, client:)` requires `client:`
334
+ with no default, because below the API boundary there is no such thing as
335
+ "the" client, and defaulting it there is exactly the bug this release
336
+ closes.
337
+ - **CHANGED**: The identity plane is renamed `identity_cache` (was
338
+ `session_cache`) and its TTL setting `identity_cache_ttl` (was
339
+ `session_cache_ttl`), because it stores one user id per session token and
340
+ never any `_Session` row or session object, and the old name led readers to
341
+ reason about `_Session` semantics that were never involved.
342
+ `Parse::Authorization::Resolved`, `::MemoryCache`, and `::InvalidSession`
343
+ replace `Parse::AtlasSearch::Session::Resolved`, `::MemoryCache`, and
344
+ `::InvalidSession`.
345
+ - **DEPRECATED**: `Parse::AtlasSearch.session_cache=`, `.role_cache=`,
346
+ `.session_cache_ttl`, `.role_cache_ttl`, `.upstream_role_reader`,
347
+ `.compare_upstream_roles`, and `Parse::AtlasSearch::Session.resolve` /
348
+ `.invalidate` / `.invalidate_user_roles` / `.reset_caches!` all still work
349
+ and delegate to the default client's context, so existing code keeps
350
+ running unchanged. Being module-level, they can only ever address
351
+ `Parse.client`; code running against a secondary application must call
352
+ `other_client.authorization` directly. Slated for removal in 6.0.
353
+ `Parse::AtlasSearch.require_session_token` is not part of this move: it
354
+ decides whether `$search` may run anonymously, which is Atlas Search's own
355
+ policy, not an identity concern.
356
+
357
+ #### Cache clears can no longer widen past what was asked
358
+
359
+ - **FIXED**: `Parse::Cache::Redis#clear` accepted `family:` and `tenant:` and
360
+ silently ignored them, falling through to the unnamespaced branch and
361
+ issuing `FLUSHDB`. A request to clear one family therefore wiped the whole
362
+ database, including other applications' entries and the
363
+ `parse-stack:foc:v1:*` create-locks, whose loss silently removes
364
+ `first_or_create!` mutual exclusion. `clear` now raises `ArgumentError` for
365
+ that combination and points callers at
366
+ `backend.scoped(keyspace).clear(family:)`, so a request to narrow a clear
367
+ can no longer widen it.
368
+ - **FIXED**: `cache_keyspace: true` on a store that cannot produce a scoped
369
+ view, such as a plain `Moneta.new(:Redis)`, left the store installed bare.
370
+ Key composition still worked, so the deployment looked correctly
371
+ keyspaced, but `Parse::Client#clear_cache!` called the store's own
372
+ unrestricted `clear`, which on Redis is `FLUSHDB`. Such stores are now
373
+ wrapped in `Parse::Cache::KeyspacedStore`, which clears by enumerating
374
+ keys under the keyspace where the store supports `each_key`, and raises
375
+ `Parse::Cache::UnscopedClearRefused` where it cannot, rather than widening
376
+ the clear to compensate.
377
+
378
+ #### The upstream-isolation probe stops mistaking silence for isolation
379
+
380
+ - **FIXED**: `Parse::Cache::Redis#verify_upstream_isolation!` reported an
381
+ empty shared database as isolated. The SCAN probe can only ever prove
382
+ sharing: an empty result is equally consistent with a genuinely separate
383
+ database and with a shared one on which Parse Server has not yet cached a
384
+ role closure, which is the state of every freshly deployed stack and
385
+ exactly when an operator runs the check. The method now falls back to
386
+ writing a random sentinel into the SDK's own database and asking the
387
+ upstream connection to read it back, and returns `true` for established
388
+ isolation, `false` for established sharing, or `:unknown` when neither
389
+ could be shown, which is what a credential restricted to
390
+ `~<appId>:role:*` produces since the sentinel read is denied and a denial
391
+ says nothing about which database denied it. `:unknown` is truthy, so
392
+ callers branching on truthiness are unaffected.
393
+
394
+ #### Generation keys stop growing without bound
395
+
396
+ - **FIXED**: Generation keys in `Parse::Cache::SubCache` never expired. One
397
+ key is written per user id on every `_User` webhook, which is unbounded
398
+ Redis growth on a public signup flow. Generation keys now expire at twice
399
+ the plane's entry TTL: expiry resets a counter to 0, which is also the
400
+ value for a subject never bumped, so a counter that outlived its entries
401
+ would let an entry written at generation 0 compare current again and
402
+ reappear after having been invalidated. `set` clamps any longer per-call
403
+ TTL to keep that invariant true.
404
+
405
+ ### Behavior Notes
406
+
407
+ - Authorization is client-owned as of this release: `client.authorization`
408
+ owns session-token resolution and role-closure expansion for that client
409
+ alone. `Parse::MongoDB` (the URI, the driver connection, and collection
410
+ selection) stays process-global in this release; the Mongo connection
411
+ itself becomes client-owned in 6.0.
412
+ - **NEW**: Because those two now have different owners, `Parse::MongoDB`
413
+ records the Parse application it was configured for and
414
+ `Parse::MongoDB.verify_client!` refuses a mongo-direct query authorized by
415
+ a client belonging to a different one, raising
416
+ `Parse::MongoDB::ClientMismatch`. Per-client authorization and a
417
+ process-global connection are each safe alone and dangerous together: a
418
+ secondary client would resolve its token correctly against its own
419
+ application, build a correct `_rperm` allow-set for one of its users, then
420
+ run the pipeline against the other application's database, where those user
421
+ ids and role names match rows they have nothing to do with. Nothing about
422
+ that looks like a failure, which is why it fails closed instead. A
423
+ connection with no recorded binding, and a caller that cannot be
424
+ identified, both proceed, so single-application deployments and master-mode
425
+ calls made before `Parse.setup` are unaffected. The guard becomes
426
+ unnecessary in 6.0.
427
+ - **NEW**: `Parse::Query#results_direct`, `#count_direct`, `#distinct_direct`,
428
+ `#distinct_direct_pointers`, and `Parse::MongoDB.aggregate` accept `client:`
429
+ alongside the existing auth keywords. It names the authorization context
430
+ that resolves the call, and it is carried onto the `Parse::ACLScope`
431
+ resolution so the binding check above has something to compare. Without it
432
+ every direct read resolved through `Parse.client`, which left the check
433
+ unable to see a second client at all and therefore unable to catch the case
434
+ it was written for. Omitting the keyword resolves through `Parse.client` as
435
+ before.
436
+ - `cache_keyspace: true` is the switch for the new cache layout, scoped
437
+ clearing, invalidation hooks, and shared identity and role planes. Left
438
+ unset, those cache behaviors are exactly as they were. `parse_cache_url:` is
439
+ separately opt-in, and without it no upstream endpoint is contacted.
440
+ - `parse_cache_url:` must address a different Redis database from `url:`. The
441
+ two are read and written by different processes with different clearing
442
+ semantics, and until the scoped-clear fix lands upstream a single `_Role`
443
+ write on the shared database destroys the SDK's cache and its create-locks.
444
+ - Create-locks deliberately keep their historical `parse-stack:foc:v1:` prefix
445
+ and are not relocated into the keyspace. Moving them would have two workers
446
+ compute different lock keys during a rolling deploy, so they would stop
447
+ contending on the same key and lose mutual exclusion for the length of the
448
+ deploy.
449
+ - The built-in upstream-role integration is compare-only and never changes
450
+ `permission_strings` or an ACL decision. A caller that directly consumes
451
+ `roles_for` as authorization input makes that database part of its trust
452
+ base, so restrict the credential to `+get +pttl` on `<appId>:role:*`.
453
+ - Webhook-driven invalidation requires the application to expose a webhook
454
+ endpoint Parse Server can reach and to have registered the hooks. Where it is
455
+ unregistered or unreachable, the TTL is the only bound on staleness.
456
+
457
+ ### Code Example
458
+
459
+ ```ruby
460
+ # The SDK's own cache on database 0, Parse Server's cache read-only on 1.
461
+ store = Parse::Cache::Redis.new(
462
+ url: "redis://localhost:6379/0",
463
+ parse_cache_url: "redis://localhost:6379/1",
464
+ )
465
+
466
+ Parse.setup(
467
+ server_url: ENV.fetch("PARSE_SERVER_URL"),
468
+ application_id: ENV.fetch("PARSE_APP_ID"),
469
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
470
+ cache: store,
471
+ expires: 10,
472
+ cache_keyspace: true, # reserved keyspace, scoped clearing, hook install
473
+ )
474
+
475
+ # Warns when both URLs resolve to the same Redis database.
476
+ store.verify_upstream_isolation!
477
+
478
+ # Share identity and role resolution across every process. Each client owns
479
+ # its own Parse::Authorization::Context, so a second client pointed at a
480
+ # second application configures its own view the same way.
481
+ view = Parse.client.sdk_cache # the scoped view derived at setup
482
+ Parse::Authorization.configure(
483
+ identity_cache: view.identity(ttl: 3600),
484
+ role_cache: view.roles(ttl: 30),
485
+ )
486
+
487
+ Parse.client.clear_cache! # scoped SCAN, because a keyspace is configured
488
+ view.clear(family: :role) # one plane
489
+ view.clear(family: :cache, tenant: "acme")
490
+ ```
491
+
3
492
  ### 5.6.0
4
493
 
5
494
  #### Voyage embeddings reach the Atlas endpoint, video, and streamed media