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