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
data/README.md CHANGED
@@ -4,6 +4,30 @@
4
4
 
5
5
  A full-featured Ruby client SDK for [Parse Server](http://parseplatform.org/). [parse-stack-next](https://github.com/neurosynq/parse-stack-next) is a Ruby client SDK, REST client, and Active Model ORM for [Parse Server](http://parseplatform.org/), combining a low-level API client, a query engine, an object-relational mapper (ORM), and a Cloud Code Webhooks rack application in a single gem.
6
6
 
7
+ ## What's new in 5.7
8
+
9
+ - **5.7.0: Reserved, app-scoped cache keyspace.** `Parse::Cache::Keyspace` lays out response, identity, and role-cache keys on a shared backend (`parse-stack:v1:<app_scope>[:<namespace>]:<family>[:T:<tenant>]:<rest>`) and owns the glob patterns that clear them again, so key generation and eviction can no longer drift apart. `app_scope` is a digest of the application id and server URL, so two apps sharing one Redis no longer collide. Enable with `cache_keyspace: true` on `Parse.setup`; left unset, behavior is unchanged. See [CHANGELOG.md](./CHANGELOG.md)
10
+ - **5.7.0: `clear_cache!` stops falling back to `FLUSHDB`.** With `cache_keyspace: true`, `Parse::Client#clear_cache!` performs a scoped SCAN inside the client's own keys instead of flushing the whole database, which previously could destroy co-tenant data and drop `first_or_create!` create-locks on a shared Redis. `flush_db!` remains the explicit opt-in for a full flush. See [CHANGELOG.md](./CHANGELOG.md)
11
+ - **5.7.0: Response-cache auth separation enforced by construction.** `Parse::Cache::Keyspace#cache_key` now requires an `auth:` discriminator for the response-cache family and refuses to build a key without one, so a master-key body and a session-token body can no longer land under the same key by accident. A non-GET write now invalidates every auth variant of a resource in one scoped pattern instead of only the variants the process has already seen. See [CHANGELOG.md](./CHANGELOG.md)
12
+ - **5.7.0: Shared identity and role planes.** `Parse::Cache::ScopedView#identity` and `#roles`, reached through `client.sdk_cache`, return `Parse::Cache::SubCache` planes for the client's authorization context, so every worker resolves a session token or role closure against one shared backend instead of its own in-process cache. Each plane invalidates by per-subject generation counter and plane-wide epoch rather than needing to enumerate entries it cannot name. See [CHANGELOG.md](./CHANGELOG.md)
13
+ - **5.7.0: Authorization becomes a client-owned module, not an Atlas Search internal.** `Parse::Authorization` now owns session-token resolution and role-closure expansion. `client.authorization` returns a `Parse::Authorization::Context`, one per `Parse::Client`, so two clients addressing two Parse applications no longer share one identity cache and one role cache, and a mongo-direct query with no `$search` anywhere in it no longer resolves identity through the Atlas Search namespace. `Parse::AtlasSearch.session_cache=`, `.role_cache=`, and `Parse::AtlasSearch::Session` remain as deprecated aliases for the default client's context, slated for removal in 6.0. See [CHANGELOG.md](./CHANGELOG.md)
14
+ - **5.7.0: Mongo-direct reads stay bound to the authorizing client.** `Parse::MongoDB.verify_client!` rejects a direct read when its authorization client belongs to a different Parse application than the process-global MongoDB connection. Direct-query entry points accept `client:` and carry it through authorization and collection binding. See [CHANGELOG.md](./CHANGELOG.md)
15
+ - **5.7.0: Effective access decisions for users and roles.** `Parse::Access.check`, `Parse::Access::Decision`, and the `can_read?` / `can_write?` / `can_delete?` helpers combine object ACLs with class-level permissions and inherited roles. Unknown evidence fails closed, and the eventual Parse Server request remains authoritative. See [CHANGELOG.md](./CHANGELOG.md)
16
+ - **5.7.0: Cache invalidation no longer depends on application discipline.** `Parse::Cache::Invalidation` registers webhook triggers on `_Role`, `_User`, and `_Session` (`after_save`/`after_delete`/`after_logout`) that keep the identity and role planes honest for writes from any client, not only the app's own logout and role-mutation code paths. Installs alongside the keyspace; disable with `cache_invalidation_hooks: false`. See [CHANGELOG.md](./CHANGELOG.md)
17
+ - **5.7.0: Optional read of Parse Server's own role cache.** `Parse::Cache::UpstreamRoles` can read the `<appId>:role:<userId>` closure Parse Server already wrote for itself. Role resolution does not consume it: the SDK still computes its own closure, and the only built-in integration is `compare_upstream_roles`, which emits a `parse.cache.role_compare` event so the two can be reconciled before anything depends on the upstream value. Call `roles_for` directly to use it. Strictly read-only, degrades to a miss on any anomaly, and `Parse::Cache::Redis#verify_upstream_isolation!` reports whether the two Redis endpoints share one database. See [CHANGELOG.md](./CHANGELOG.md)
18
+
19
+ See [CHANGELOG.md](./CHANGELOG.md) for the full 5.7 entry, including behavior notes and a complete setup example.
20
+
21
+ ## What's new in 5.6
22
+
23
+ - **5.6.0: Voyage embeddings reach the Atlas endpoint, video, and streamed media.** The Voyage provider now targets MongoDB's Atlas Embedding and Reranking API as well as Voyage's own; an Atlas-prefixed key routes automatically, or pass `endpoint: :atlas` / `:voyage` explicitly. Adds `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-2`, and `voyage-multimodal-3.5`, plus `embed_video`. `Parse::Embeddings::MediaFile` streams local image and video uploads into the request body in fixed-size chunks instead of buffering them, bounding peak memory regardless of file size. See [CHANGELOG.md](./CHANGELOG.md)
24
+ - **5.6.0 (Breaking): Voyage's default model moves to `voyage-3.5`.** `voyage-3` is retired from the Atlas endpoint, so the old default failed at construction for an Atlas key with no model named. Code relying on the previous default should pin `model: "voyage-3"` to keep existing embeddings valid, or re-embed against `voyage-3.5`. See [CHANGELOG.md](./CHANGELOG.md)
25
+ - **5.6.0: Vector search no longer underfills.** `$vectorSearch` applied its `limit` before the SDK's ACL match, `protectedFields` redaction, and pointer filtering, so a scoped caller who could read 2 of the top 10 documents got 2 results even when hundreds of readable matches existed further down the ranking. The search now requests a wider internal candidate window, applies every enforcement layer, and only then trims to the requested count; a `candidate_limit:` option tunes the window, and a `parse.vector_search.search` notification reports attrition. Hybrid search's own candidate-window and fusion-depth bugs are fixed the same way. See [CHANGELOG.md](./CHANGELOG.md)
26
+ - **5.6.0: `:vector` properties are checked against the provider actually registered.** A property's declared `model:` was recorded but never enforced, so swapping the registered provider's model could silently mix incomparable vectors into one index with no error. `Parse::Embeddings::BindingAudit` now checks `model:` and `dimensions:` before any request, on both the managed-write and query-embedding paths, and fails closed if the provider cannot report either. `Parse::Embeddings::BindingAudit.audit_all!` checks every declared binding at once for a boot-time or CI gate. See [CHANGELOG.md](./CHANGELOG.md)
27
+ - **5.6.0: Corrected the model dimension table.** The entire Voyage v4 family defaults to 1024; `voyage-4-large`'s 2048 and `voyage-4-lite`'s 512 were recorded as native widths when they are Matryoshka options reached only by requesting them, which made both models raise `Parse::Embeddings::InvalidResponseError` on every call. Any width on a model's Matryoshka ladder is now accepted. See [CHANGELOG.md](./CHANGELOG.md)
28
+
29
+ See [CHANGELOG.md](./CHANGELOG.md) for the full 5.6 entry.
30
+
7
31
  ## What's new in 5.5
8
32
 
9
33
  - **5.5.0 — Multimodal bytes-fetch with magic-byte MIME verification** — `embed_image ..., source: :bytes` has the SDK download an image itself through the `Parse::File.safe_open_url` SSRF primitive, verify the content by **magic-byte sniff** (the `Content-Type` header is never consulted — a `.jpg` URL serving HTML is refused), cross-check the URL extension, enforce a `Parse::Embeddings.allowed_image_types` allowlist, strip EXIF/XMP metadata **by default** (JPEG APP1, PNG `eXIf`, WebP `EXIF`/`XMP ` chunks; opt out with `exif_strip: false`), and forward the verified bytes to Voyage/Cohere as a base64 data URI. No provider-side URL fetch occurs, so the `trust_provider_url_fetch` sentinel is not required — the host allowlist still applies. See [CHANGELOG.md](./CHANGELOG.md)
@@ -64,7 +88,7 @@ See [CHANGELOG.md](./CHANGELOG.md) for the full 5.1 entry, including breaking ch
64
88
  - **MCP transport hardening** — Streamable HTTP `Mcp-Session-Id` header (renamed from `X-MCP-Session-Id`, **breaking**), `MCP-Protocol-Version` validation, `DELETE /` session termination, structured-content (`outputSchema`) on built-in tools, optional `health_path:` liveness probe
65
89
  - **`Parse::GraphQL::TypeGenerator`** — generate `graphql-ruby` types directly from your `Parse::Object` subclasses (no Parse Server round-trip), with `:vector` columns surfaced as `[Float]` and association registries (`has_one_associations`, `has_many_associations`) populated at DSL time
66
90
  - **LiveQuery promoted to stable** — the experimental warning is removed; `Parse.live_query_enabled = true` is retained as a network-egress safety toggle, not a stability gate
67
- - **Server-version deprecation warning** — one-shot warning when connecting to a Parse Server older than the configured threshold (default `7.0.0`, override with `PARSE_DEPRECATED_SERVER_VERSION_BELOW`); silence with `Parse.suppress_server_version_warning = true`. The **supported baseline is Parse Server 9.x** (the SDK is developed and tested against a pinned `parse-server:9.9.0`); the default warning threshold is intentionally conservative so older deployments only get an advisory, not a hard break.
91
+ - **Server-version deprecation warning** — one-shot warning when connecting to a Parse Server older than the configured threshold (default `7.0.0`, override with `PARSE_DEPRECATED_SERVER_VERSION_BELOW`); silence with `Parse.suppress_server_version_warning = true`. The **supported baseline is Parse Server 9.x** (the SDK is developed and tested against a pinned `parse-server:9.10.0`); the default warning threshold is intentionally conservative so older deployments only get an advisory, not a hard break.
68
92
  - **`mongo_relation_index :field, dedup: true`** — register a compound `{owningId, relatedId}` UNIQUE on relation join collections to prevent duplicate-pair subscriptions without breaking `has_many` semantics
69
93
 
70
94
  See [CHANGELOG.md](./CHANGELOG.md) for the full 5.0 entry, including security-hardening notes and Ruby 3.x cleanup.
@@ -665,6 +689,271 @@ Redis is the recommended cache backend for multi-process / multi-dyno deployment
665
689
 
666
690
  The cache surface is opt-in at two layers. Object fetches (`Model.find(id)`, `obj.reload!` in non-write-only mode) cache by default once a store is configured. Query results do **not** cache by default — pass `cache: true` per call (e.g. `Song.all(limit: 500, cache: true)`) or set `Parse.default_query_cache = true` for opt-out behavior. Both layers honor `cache: false` / `Cache-Control: no-cache` to skip the cache for an individual request.
667
691
 
692
+ [docs/caching.md](docs/caching.md) covers the whole picture: every cache the SDK keeps, which ones are process-local, how keys are scoped by auth and tenant, what invalidates what, and how to clear safely.
693
+
694
+ #### `:cache_keyspace`
695
+
696
+ Set `cache_keyspace: true` to place response, identity, and role-cache keys
697
+ inside a reserved, app-scoped layout:
698
+
699
+ ```
700
+ parse-stack:v1:<app_scope>[:<namespace>]:<family>[:T:<tenant>]:<rest>
701
+ ```
702
+
703
+ `app_scope` is a digest of your `application_id` and `server_url`, so two Parse
704
+ apps sharing one Redis stay separated even when neither sets a
705
+ `cache_namespace:`. The families are `cache` (the response cache), `idn`
706
+ (session-token identity), and `role` (role closures).
707
+
708
+ The keyspace is what makes clearing safe. Without it, `clear_cache!` falls
709
+ through to `FLUSHDB` whenever the `Parse::Cache::Redis` wrapper itself has no
710
+ namespace, which is the default. On a shared Redis that removes other tenants'
711
+ data, and it deletes the SDK's own `parse-stack:foc:v1:*` create-locks, so any
712
+ `first_or_create!` holding a lock at that moment silently loses its mutual
713
+ exclusion. With a keyspace configured, every clear is a scoped `SCAN` restricted
714
+ to this client's own keys. `flush_db!` stays available as the explicit opt-in
715
+ for a full flush.
716
+
717
+ The keyspace's response-key builder requires an auth discriminator, so a
718
+ master-key response and a session-token response for the same URL cannot share
719
+ an entry by accident. Enabling the keyspace also registers the webhook
720
+ invalidation triggers described below. Pass `cache_invalidation_hooks: false`
721
+ to skip the trigger registration.
722
+
723
+ This is opt-in and inert by default. With `cache_keyspace:` unset, the key
724
+ shape and every behavior above are unchanged from earlier releases.
725
+
726
+ ```ruby
727
+ store = Parse::Cache::Redis.new(url: "redis://localhost:6379/0")
728
+
729
+ Parse.setup(
730
+ server_url: ENV.fetch("PARSE_SERVER_URL"),
731
+ application_id: ENV.fetch("PARSE_APP_ID"),
732
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
733
+ cache: store,
734
+ expires: 10,
735
+ cache_keyspace: true,
736
+ cache_namespace: "web", # optional, composes into the layout above
737
+ )
738
+
739
+ # `Parse.setup` derives an immutable, per-client Parse::Cache::ScopedView
740
+ # from `store` and installs THAT as this client's SDK cache. `store` itself
741
+ # is never mutated: a keyspace can only ever be bound through a scoped view,
742
+ # so the same `store` can back several clients/apps without one's keyspace
743
+ # ever clobbering another's. Reach the scoped keyspace through
744
+ # `Parse.sdk_cache`; `Parse.cache` is still `store` itself, for your keys.
745
+ Parse.client.clear_cache! # scoped SCAN over this client's keys
746
+ Parse.sdk_cache.clear(family: :role) # one family only
747
+ Parse.sdk_cache.clear(family: :cache, tenant: "acme") # one tenant of one family
748
+ store.flush_db! # explicit full flush of the WHOLE backend, ops tooling only
749
+ ```
750
+
751
+ Scoped eviction uses `UNLINK` where the client supports it and emits a
752
+ `parse.cache.evict` `ActiveSupport::Notifications` event with
753
+ `pattern_digest`, `deleted`, and `duration_ms`.
754
+
755
+ #### Shared identity and role caches
756
+
757
+ `Parse::Authorization` resolves a session token to a user and a user to a role
758
+ closure for every scoped mongo-direct read, including Atlas Search's
759
+ `$search`. Each `Parse::Client` owns its own `Parse::Authorization::Context`
760
+ (`client.authorization`), so two clients addressing two Parse applications
761
+ never resolve a token against each other's caches or each other's
762
+ `/users/me`. Both resolutions are cached in per-process memory by default, so
763
+ each Puma worker and each dyno maintains its own copy. Once
764
+ `cache_keyspace: true` is set, the client's scoped view
765
+ (`Parse.client.sdk_cache`) exposes two planes that drop into those slots and
766
+ move the caches to the shared backend:
767
+
768
+ ```ruby
769
+ # `Parse.client.sdk_cache` is the scoped view the client derived at setup. The
770
+ # planes live on the view rather than on the backend, so two clients sharing
771
+ # one Redis connection cannot end up sharing each other's caches.
772
+ view = Parse.client.sdk_cache
773
+ Parse::Authorization.configure(
774
+ identity_cache: view.identity(ttl: 3600),
775
+ role_cache: view.roles(ttl: 30),
776
+ )
777
+
778
+ # A named client pointed at a second Parse application configures its own
779
+ # context directly. Parse::Authorization.configure only ever reaches the
780
+ # default client, by design: there is no such thing as "the" client below
781
+ # that boundary.
782
+ other_view = other_client.sdk_cache
783
+ other_client.authorization.configure(
784
+ identity_cache: other_view.identity(ttl: 3600),
785
+ role_cache: other_view.roles(ttl: 30),
786
+ )
787
+ ```
788
+
789
+ Each plane writes inside its own keyspace family, so clearing one reaches
790
+ neither the other nor the response cache. Both require `cache_keyspace: true`.
791
+ `Parse::AtlasSearch.session_cache=` and `.role_cache=` remain as deprecated
792
+ aliases for the default client's context and are slated for removal in 6.0.
793
+
794
+ Staleness is bounded by webhook rather than by application code.
795
+ `Parse::Cache::Invalidation` registers `after_save` and `after_delete` on
796
+ `_Role` and `_User` plus `after_logout` on `_Session`, so a role change made by
797
+ any client (a mobile SDK, the dashboard, Node cloud code) invalidates the planes
798
+ the same way an SDK write does. This requires the webhook endpoint to be
799
+ registered and reachable from Parse Server; where it is not, the TTL remains the
800
+ only bound on staleness.
801
+
802
+ #### Client-owned authorization meets a process-global MongoDB connection
803
+
804
+ Authorization is per client as of 5.7. The MongoDB connection is not:
805
+ `Parse::MongoDB.configure` sets one URI, one database, and one driver client
806
+ for the whole process, and that stays true until 6.0. Each half is safe on its
807
+ own and the combination is not.
808
+
809
+ A second client would resolve its session token correctly, against its own
810
+ Parse application, and build a correct `_rperm` allow-set for one of its users.
811
+ The resulting pipeline would then run against the *other* application's
812
+ database, where those user ids and role names are matched against rows they
813
+ have nothing to do with. Any collision is a cross-application read, and
814
+ nothing about it looks like a failure. It looks like a query that returned few
815
+ rows.
816
+
817
+ `Parse::MongoDB` therefore records the application it was configured for, and
818
+ `Parse::MongoDB.verify_client!` refuses any mongo-direct query whose
819
+ authorization came from a client belonging to a different one, raising
820
+ `Parse::MongoDB::ClientMismatch`. The check runs in two places, because
821
+ `aggregate` is not the only way to reach the database: Atlas Search builds and
822
+ runs its own `$search` pipelines, and hybrid vector search does the same, both
823
+ going straight to the driver. Both verify against the client that authorized
824
+ the call: `aggregate` passes the one it resolved, and
825
+ `Parse::MongoDB.collection` takes an `authorizing_client:` that each of those
826
+ paths forwards. `collection` does NOT substitute the default for a missing
827
+ one, because doing so would present a call site that forgot to forward its
828
+ client as the default and wave it through, which is the omission the check
829
+ exists to catch. Public entry points resolve an omitted client to the default
830
+ once, and internal sinks treat nil as unidentified.
831
+
832
+ `results_direct`, `count_direct`, `distinct_direct`,
833
+ `distinct_direct_pointers`, and `Parse::MongoDB.aggregate` all take `client:`
834
+ alongside the other auth keywords. It selects the authorization context that
835
+ resolves the call, and it is what the guard compares against the binding:
836
+
837
+ ```ruby
838
+ Parse.setup(application_id: "appA", ...)
839
+ Parse::MongoDB.configure(uri: ENV.fetch("DATABASE_URI")) # bound to appA
840
+
841
+ other = Parse::Client.new(application_id: "appB", ...)
842
+
843
+ # Resolves appB's token against appB, then would read appA's database. Refused.
844
+ Post.query.results_direct(session_token: token, client: other)
845
+ # => Parse::MongoDB::ClientMismatch
846
+
847
+ # A query can carry the client instead of repeating it per call. Assign it;
848
+ # `Post.query(client: other)` would build a constraint on a field named
849
+ # `client` and match nothing.
850
+ q = Post.query
851
+ q.client = other
852
+ q.results_direct(session_token: token)
853
+ ```
854
+
855
+ It fires the same way when the default client is replaced after MongoDB was
856
+ configured, which needs no explicit `client:` at all:
857
+
858
+ ```ruby
859
+ Parse.setup(application_id: "appB", ...) # default is now appB, binding is appA
860
+ Post.query.results_direct(session_token: token)
861
+ # => Parse::MongoDB::ClientMismatch
862
+ ```
863
+
864
+ Omitting `client:` resolves through `Parse.client`, which is the existing
865
+ behavior and what every single-application deployment gets.
866
+
867
+ The binding comes from the default client, which is the one whose
868
+ configuration produced the connection. It is established at `configure` time,
869
+ or on first use when MongoDB was configured before `Parse.setup`, so that boot
870
+ order does not leave the check disabled. It is never taken from whichever
871
+ caller happens to arrive first: in a process that configures MongoDB early,
872
+ installs default client A, then makes its first direct request explicitly as
873
+ B, the connection belongs to A and B's read is refused.
874
+
875
+ A caller that cannot name its client is treated as unidentified, never
876
+ upgraded to the default. What happens next depends on how many applications
877
+ the process has seen:
878
+
879
+ - **One application.** Unidentified callers proceed. This is every
880
+ single-application deployment, and it includes master-mode and
881
+ public-fallback resolutions produced before `Parse.setup`.
882
+ - **Two or more.** Unidentified callers are refused. A call path that fails to
883
+ forward its client fails loudly rather than silently reading whichever
884
+ database happened to be bound, so the completeness of that plumbing is not
885
+ the only thing keeping two applications apart.
886
+
887
+ The owning client counts toward that tally even if it never issues a direct
888
+ read itself, so a process where only the second application ever identifies
889
+ itself still counts as two.
890
+
891
+ If you genuinely need two applications in one process, give each its own
892
+ process, or route the second one's reads through REST, where Parse Server
893
+ enforces ACLs itself. The guard becomes unnecessary in 6.0, when the
894
+ connection becomes client-owned.
895
+
896
+ #### `:parse_cache_url`
897
+
898
+ Parse Server keeps its own role cache, writing the transitive closure for a user
899
+ as `<appId>:role:<userId>`. Pointing `Parse::Cache::Redis` at it lets the SDK
900
+ compare that value with its own role-graph result, or lets trusted application
901
+ code read it explicitly when a webhook payload already supplies a user id.
902
+
903
+ ```ruby
904
+ store = Parse::Cache::Redis.new(
905
+ url: "redis://localhost:6379/0", # the SDK's own cache
906
+ parse_cache_url: "redis://localhost:6379/1", # Parse Server's cache, read-only
907
+ )
908
+ Parse.setup(cache: store, cache_keyspace: true, ...)
909
+ view = Parse.client.sdk_cache
910
+
911
+ # true (isolated), false (shared, and warned about), or :unknown.
912
+ store.verify_upstream_isolation!
913
+ ```
914
+
915
+ **The two URLs must address different Redis databases.** On Parse Server 9.10.0
916
+ and earlier, a `_Role` write clears the cache with `FLUSHDB`, which on a shared
917
+ database deletes the SDK's cached responses and its create-locks along with it
918
+ ([parse-server#10617](https://github.com/parse-community/parse-server/issues/10617)).
919
+ `verify_upstream_isolation!` detects this by scanning the SDK's own database
920
+ for a key shaped like one Parse Server would have written, and, when that finds
921
+ nothing, by writing a random sentinel to the SDK's database and asking the
922
+ upstream connection to read it back. The scan alone can only prove sharing: an
923
+ empty result looks identical on a separate database and on a shared one where
924
+ Parse Server has not cached a role closure yet. It returns `true` for
925
+ established isolation, `false` for established sharing, and `:unknown` when
926
+ neither could be shown, which is what the restricted credential below produces
927
+ since the sentinel read is denied. `:unknown` is truthy. Comparing URL strings
928
+ cannot substitute for any of this: `localhost` against `127.0.0.1`, CNAMEs,
929
+ Sentinel and Cluster topologies, and a database selected outside the URL all
930
+ defeat it. It warns rather than refusing to boot, because the hazard disappears
931
+ on a server carrying the scoped-clear fix.
932
+
933
+ Role resolution never consumes the upstream value. `Parse::Authorization`
934
+ computes its own closure, and the only built-in integration is
935
+ `compare_upstream_roles`, which reads the upstream entry solely to emit a
936
+ `parse.cache.role_compare` event. Call `view.upstream_roles.roles_for(user_id)`
937
+ yourself to use the value.
938
+
939
+ The attachment is strictly read-only. The SDK never writes that keyspace: its
940
+ own closure is depth-capped while Parse Server's is not, so writing a subset
941
+ into a cache the server treats as authoritative would under-permission users.
942
+ Every failure mode (a miss, malformed JSON, an entry whose remaining TTL cannot
943
+ be read or is implausibly long, an entry older than the SDK's last role
944
+ invalidation, a transport error) degrades to a miss and the closure is
945
+ recomputed. It never fails open.
946
+
947
+ If application code consumes those role names for authorization, that database
948
+ becomes part of its trust base. Restrict the credential accordingly:
949
+
950
+ ```
951
+ ACL SETUSER parse-stack-role-reader on >SECRET \
952
+ ~<appId>:role:* resetchannels -@all +get +pttl
953
+ ```
954
+
955
+ `+pttl` is required alongside `+get`; the freshness guard cannot run without it.
956
+
668
957
  #### `:expires`
669
958
  Sets the default cache expiration time (in seconds) for successful non-empty `GET` requests when using the caching middleware. The default value is 3 seconds. If `:expires` is set to 0, caching will be disabled. You can always clear the current state of the cache using the `clear_cache!` method on your `Parse::Client` instance.
670
959
 
@@ -3450,6 +3739,33 @@ Song.query(cache: true).first # Explicitly uses cache
3450
3739
  You may access the shared cache for the default client connection through `Parse.cache`. This is useful if you
3451
3740
  want to utilize the same cache store for other purposes.
3452
3741
 
3742
+ `Parse.cache` is exactly the store you configured, and it is not affected by
3743
+ `cache_keyspace: true`, which only scopes the SDK's own slice. That slice is
3744
+ `Parse.sdk_cache`.
3745
+
3746
+ **`clear_cache!` only spares your keys when `cache_keyspace: true` is set.**
3747
+ Without it there is no keyspace to confine the clear to, `sdk_cache` and
3748
+ `cache` are the same object, and `clear_cache!` falls back to the store's own
3749
+ `clear`. What that reaches depends on the store, so there are two axes, not
3750
+ one:
3751
+
3752
+ For `Parse::Cache::Redis`, which is the only store that implements a
3753
+ namespace:
3754
+
3755
+ | | no `namespace:` | `namespace: "web"` |
3756
+ |---|---|---|
3757
+ | **no `cache_keyspace`** | clears everything on the store | clears `web:*` only |
3758
+ | **`cache_keyspace: true`** | clears the SDK keyspace only | clears the SDK keyspace only |
3759
+
3760
+ For any other Moneta store there is no middle column. `cache_namespace:` is a
3761
+ key-prefix option for the SDK's own keys and does not make the store's `clear`
3762
+ selective, so without `cache_keyspace: true` a `clear_cache!` takes the whole
3763
+ store regardless of any namespace you set.
3764
+
3765
+ Turn `cache_keyspace: true` on before relying on the separation. A Redis
3766
+ namespace narrows the blast radius but is not the same guarantee: it still
3767
+ takes any of your own keys that happen to sit under the same prefix.
3768
+
3453
3769
  ```ruby
3454
3770
  # Access the cache instance for other uses
3455
3771
  Parse.cache["key"] = "value"
@@ -3464,6 +3780,38 @@ end
3464
3780
 
3465
3781
  ```
3466
3782
 
3783
+ **`Parse.cache` versus `Parse.sdk_cache`.** The two answer different questions
3784
+ and only one of them is yours.
3785
+
3786
+ | | `Parse.cache` | `Parse.sdk_cache` |
3787
+ |---|---|---|
3788
+ | What it is | the store you configured, unchanged | the SDK's slice of it |
3789
+ | Key names | the keys you write | prefixed with the SDK keyspace |
3790
+ | Scoped by `cache_keyspace: true` | no | yes |
3791
+ | What `clear_cache!` clears (keyspaced) | nothing | this |
3792
+ | What `clear_cache!` clears (not keyspaced) | the store's own `clear`, they are the same object | |
3793
+
3794
+ `clear_cache!` operates on `sdk_cache`. With a keyspace that is only the SDK's
3795
+ response-cache entries and its identity and role planes. Without one, see the
3796
+ caveat above.
3797
+
3798
+ `Parse.cache.clear` is a different thing entirely and stays available on
3799
+ purpose, but what it reaches depends on how the store was built:
3800
+
3801
+ - **No `namespace:`** it clears the whole store. On `Parse::Cache::Redis` that
3802
+ is `FLUSHDB`: your keys, the SDK's, any other application on that database,
3803
+ and the `parse-stack:foc:v1:*` create-locks whose loss silently drops
3804
+ `first_or_create!` mutual exclusion.
3805
+ - **With `namespace:`** it scan-deletes `<namespace>:*` only, so keys outside
3806
+ that prefix survive.
3807
+
3808
+ `Parse::Cache::Redis#flush_db!` is the unconditionally total operation. Reach
3809
+ for either only when you own the whole database.
3810
+
3811
+ For anything beyond incidental use, give your application its own separately
3812
+ named Moneta instance rather than sharing the SDK's. Sharing one store means
3813
+ sharing its eviction policy and its failure modes.
3814
+
3467
3815
  #### :use_master_key
3468
3816
  A true/false value. If you provided a master key as part of `Parse.setup()`, it will be sent on every request. However, if you wish to disable sending the master key on a particular request in order for the record ACLs to be enforced, you may pass `false`. If `false` is passed, caching will be disabled for this request.
3469
3817
 
@@ -5726,17 +6074,37 @@ result = Parse::AtlasSearch.search("Song", "love", master: true)
5726
6074
  # missing-auth call an `ACLRequired` error instead.
5727
6075
  ```
5728
6076
 
5729
- Caching for session-token lookups is configurable:
6077
+ Caching for session-token lookups is configurable through
6078
+ `Parse::Authorization`, which owns resolution for every client, not just Atlas
6079
+ Search:
5730
6080
 
5731
6081
  ```ruby
5732
- Parse::AtlasSearch.session_cache_ttl = 3600 # token → user_id
5733
- Parse::AtlasSearch.role_cache_ttl = 120 # user_id → role names
6082
+ Parse.client.authorization.identity_cache_ttl = 3600 # token → user_id
6083
+ Parse.client.authorization.role_cache_ttl = 120 # user_id → role names
5734
6084
 
5735
6085
  # Force re-resolution after logout / role mutation:
5736
- Parse::AtlasSearch::Session.invalidate(token)
5737
- Parse::AtlasSearch::Session.invalidate_user_roles(user_id)
6086
+ Parse.client.authorization.invalidate(token)
6087
+ Parse.client.authorization.invalidate_user_roles(user_id)
5738
6088
  ```
5739
6089
 
6090
+ Both caches default to per-process memory. With a keyspaced
6091
+ `Parse::Cache::Redis` you can move them to the shared backend so every worker
6092
+ resolves against the same view, and let webhook triggers invalidate them
6093
+ instead of calling `invalidate` from your own logout and role-mutation paths:
6094
+
6095
+ ```ruby
6096
+ # `Parse.client.sdk_cache` is the scoped view the client derived at setup. The
6097
+ # planes live on the view rather than on the backend, so two clients sharing
6098
+ # one Redis connection cannot end up sharing each other's caches.
6099
+ view = Parse.client.sdk_cache
6100
+ Parse::Authorization.configure(
6101
+ identity_cache: view.identity(ttl: 3600),
6102
+ role_cache: view.roles(ttl: 30),
6103
+ )
6104
+ ```
6105
+
6106
+ See [Shared identity and role caches](#shared-identity-and-role-caches).
6107
+
5740
6108
  Notes:
5741
6109
 
5742
6110
  - `faceted_search` cannot ACL-filter `$searchMeta` bucket counts and
@@ -5747,6 +6115,10 @@ Notes:
5747
6115
  direction: a user's permissions include any role whose `roles`
5748
6116
  relation transitively contains a role the user directly belongs
5749
6117
  to. See `Parse::Role.all_for_user` for the primitive.
6118
+ - `Parse::AtlasSearch.session_cache_ttl=`, `.role_cache_ttl=`, and
6119
+ `Parse::AtlasSearch::Session.invalidate` / `.invalidate_user_roles` still
6120
+ work. They delegate to the default client's `Parse::Authorization::Context`
6121
+ and are slated for removal in 6.0.
5750
6122
 
5751
6123
  ### Full-Text Search
5752
6124