parse-stack-next 5.2.1 → 5.4.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +616 -0
  5. data/Gemfile +7 -0
  6. data/Gemfile.lock +12 -4
  7. data/README.md +296 -3
  8. data/Rakefile +243 -41
  9. data/docs/atlas_vector_search_guide.md +86 -2
  10. data/docs/client_sdk_guide.md +38 -0
  11. data/docs/mcp_guide.md +119 -4
  12. data/docs/mongodb_direct_guide.md +93 -1
  13. data/docs/usage_guide.md +11 -1
  14. data/docs/webhooks_guide.md +418 -0
  15. data/examples/README.md +46 -0
  16. data/examples/basic_client.rb +93 -0
  17. data/examples/basic_server.rb +109 -0
  18. data/examples/live_query_listener.rb +98 -0
  19. data/examples/rag_chatbot.rb +221 -0
  20. data/examples/webhook_server.rb +111 -0
  21. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  22. data/lib/parse/agent/tools.rb +45 -5
  23. data/lib/parse/api/aggregate.rb +7 -1
  24. data/lib/parse/api/cloud_functions.rb +12 -4
  25. data/lib/parse/api/hooks.rb +46 -9
  26. data/lib/parse/api/objects.rb +16 -2
  27. data/lib/parse/api/path_segment.rb +33 -0
  28. data/lib/parse/api/server.rb +94 -0
  29. data/lib/parse/api/users.rb +58 -2
  30. data/lib/parse/atlas_search.rb +7 -7
  31. data/lib/parse/client/body_builder.rb +5 -0
  32. data/lib/parse/client/protocol.rb +4 -0
  33. data/lib/parse/client.rb +174 -9
  34. data/lib/parse/embeddings/spend_cap.rb +255 -0
  35. data/lib/parse/embeddings.rb +1 -0
  36. data/lib/parse/live_query/client.rb +3 -1
  37. data/lib/parse/live_query/subscription.rb +32 -5
  38. data/lib/parse/model/acl.rb +4 -2
  39. data/lib/parse/model/associations/belongs_to.rb +47 -0
  40. data/lib/parse/model/classes/audience.rb +52 -4
  41. data/lib/parse/model/classes/user.rb +200 -3
  42. data/lib/parse/model/core/embed_managed.rb +113 -0
  43. data/lib/parse/model/core/pluralized_aliases.rb +30 -0
  44. data/lib/parse/model/core/properties.rb +27 -0
  45. data/lib/parse/model/core/querying.rb +73 -1
  46. data/lib/parse/model/core/vector_searchable.rb +161 -0
  47. data/lib/parse/model/file.rb +35 -2
  48. data/lib/parse/model/object.rb +28 -5
  49. data/lib/parse/mongodb.rb +7 -1
  50. data/lib/parse/pipeline_security.rb +5 -3
  51. data/lib/parse/query/constraints.rb +29 -0
  52. data/lib/parse/query.rb +265 -27
  53. data/lib/parse/retrieval/agent_tool.rb +49 -0
  54. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  55. data/lib/parse/retrieval/reranker.rb +157 -0
  56. data/lib/parse/retrieval/retriever.rb +110 -23
  57. data/lib/parse/stack/version.rb +1 -1
  58. data/lib/parse/stack.rb +173 -1
  59. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  60. data/lib/parse/vector_search/hybrid.rb +578 -0
  61. data/lib/parse/webhooks/payload.rb +399 -11
  62. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  63. data/lib/parse/webhooks.rb +215 -3
  64. data/scripts/docker/Dockerfile.parse +5 -1
  65. data/scripts/docker/docker-compose.test.yml +31 -0
  66. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  67. data/scripts/docker/preflight.sh +76 -0
  68. data/scripts/start-parse.sh +52 -4
  69. metadata +16 -1
data/Gemfile CHANGED
@@ -32,5 +32,12 @@ group :test, :development do
32
32
  gem "puma"
33
33
  gem "sinatra"
34
34
  gem "rack-test"
35
+ # MFA / TOTP test infrastructure (Parse::MFA, two_factor_auth).
36
+ # rotp: generates TOTP secrets and time-based codes so the MFA unit and
37
+ # integration tests can enroll and log in against Parse Server's
38
+ # TOTP adapter (SHA1 / 6 digits / 30s — rotp's defaults match).
39
+ # rqrcode: renders the provisioning QR code exercised by Parse::MFA.qr_code.
40
+ gem "rotp"
41
+ gem "rqrcode"
35
42
  # gem "thin" # for yard server - disabled due to eventmachine compilation issues
36
43
  end
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- parse-stack-next (5.2.1)
4
+ parse-stack-next (5.4.0)
5
5
  activemodel (>= 6.1, < 9)
6
6
  activesupport (>= 6.1, < 9)
7
7
  connection_pool (>= 2.2, < 4)
@@ -39,6 +39,7 @@ GEM
39
39
  bundler-audit (0.9.3)
40
40
  bundler (>= 1.2.0)
41
41
  thor (~> 1.0)
42
+ chunky_png (1.4.0)
42
43
  coderay (1.1.3)
43
44
  concurrent-ruby (1.3.6)
44
45
  connection_pool (3.0.2)
@@ -54,7 +55,7 @@ GEM
54
55
  faraday-net_http (>= 2.0, < 3.5)
55
56
  json
56
57
  logger
57
- faraday-net_http (3.4.3)
58
+ faraday-net_http (3.4.4)
58
59
  net-http (~> 0.5)
59
60
  faraday-net_http_persistent (2.3.1)
60
61
  faraday (~> 2.5)
@@ -72,7 +73,7 @@ GEM
72
73
  prism (>= 1.3.0)
73
74
  rdoc (>= 4.0.0)
74
75
  reline (>= 0.4.2)
75
- json (2.19.5)
76
+ json (2.19.8)
76
77
  logger (1.7.0)
77
78
  method_source (1.1.0)
78
79
  minitest (6.0.6)
@@ -104,7 +105,7 @@ GEM
104
105
  coderay (~> 1.1)
105
106
  method_source (~> 1.0)
106
107
  reline (>= 0.6.0)
107
- psych (5.3.1)
108
+ psych (5.4.0)
108
109
  date
109
110
  stringio
110
111
  puma (8.0.2)
@@ -133,6 +134,11 @@ GEM
133
134
  connection_pool
134
135
  reline (0.6.3)
135
136
  io-console (~> 0.5)
137
+ rotp (6.3.0)
138
+ rqrcode (3.2.0)
139
+ chunky_png (~> 1.0)
140
+ rqrcode_core (~> 2.0)
141
+ rqrcode_core (2.1.0)
136
142
  ruby-progressbar (1.13.0)
137
143
  rufo (0.18.2)
138
144
  securerandom (0.4.1)
@@ -181,6 +187,8 @@ DEPENDENCIES
181
187
  rake
182
188
  redcarpet
183
189
  redis
190
+ rotp
191
+ rqrcode
184
192
  rufo
185
193
  sinatra
186
194
  webrick
data/README.md CHANGED
@@ -4,6 +4,21 @@
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.4
8
+
9
+ - **5.4.0 — Hybrid search + reranking for RAG** — `Class.hybrid_search(text:, lexical:, vector:, k:, fusion:)` fuses a lexical Atlas Search branch with a `$vectorSearch` branch using reciprocal-rank fusion (RRF): lexical search nails exact tokens (codes, proper nouns), vector search nails paraphrase, and fusing the two beats either alone. Each branch enforces ACL/CLP independently before fusion (no separate hydration fetch to secure); results carry `#hybrid_score` / `#hybrid_ranks`. `Parse::VectorSearch::Hybrid.rank_fusion_supported?` detects Atlas 8.0+ native `$rankFusion` by a cached behavioural probe (native execution is opt-in; client-side RRF is the always-enforced default). `Parse::Retrieval::Reranker` adds cross-encoder reranking (`Reranker::Cohere` over `/v2/rerank`, plus a deterministic `Reranker::Fixture`), wired into `Parse::Retrieval.retrieve(hybrid:, rerank:)`. `Parse::Embeddings::SpendCap` adds an opt-in per-tenant embedding token cap (hard-refuse) at the `semantic_search` agent-tool boundary. See [CHANGELOG.md](./CHANGELOG.md) and [`docs/atlas_vector_search_guide.md`](./docs/atlas_vector_search_guide.md)
10
+ - **5.4.0 — Vector backfill, visibility, and webhook redaction** — `Class.embed_pending!` backfills embeddings for records whose managed `:vector` field is null (objectId-cursor pagination); `Parse::Object#compute_embedding!` forces an in-place recompute without a save; `vector_visibility :owner_only | :public` controls whether a class's vectors appear in `as_json` by default; and webhook trigger payloads now strip declared `:vector` columns by default (a `:public` class keeps them). See [CHANGELOG.md](./CHANGELOG.md)
11
+ - **5.4.0 — TOTP multi-factor auth works end to end** — the `Parse::User` MFA lifecycle is now fully functional and exercised against a real MFA-enabled Parse Server. `setup_mfa!(secret:, token:)` enrolls TOTP and returns recovery codes; `Parse::User.login_with_mfa(user, pass, code)` completes a second-factor login; `mfa_enabled?` / `mfa_status` report enrollment after an ordinary fetch — the SDK strips the raw TOTP secret and recovery codes that Parse Server returns in `authData` but preserves a leak-safe `{status: "enabled"}` projection so the status reads correctly without exposing the secret; `disable_mfa!(current_token:)` turns MFA off after re-validating the current code (a wrong code raises `Parse::MFA::VerificationError`), and `disable_mfa_master_key!(authorized_by:)` is the operator override. Each MFA write also no longer raises an internal argument error before reaching the server. Interactively, `rake client:console` now prompts for a TOTP / recovery code (or reads `PARSE_LOGIN_MFA`) when logging into an enrolled account. See [CHANGELOG.md](./CHANGELOG.md)
12
+ - **5.4.0 — Request email-address verification** — `Parse::User.request_email_verification(email)` and the instance `Parse::User#request_email_verification` ask Parse Server to (re)send the verification email for a registered, not-yet-verified user, mirroring `request_password_reset` (per-email rate limiting, Boolean return). Requires a server email adapter with `verifyUserEmails` enabled. See [CHANGELOG.md](./CHANGELOG.md)
13
+ - **5.4.0 — Audience hash queries persist correctly** — `Parse::Audience#query` is now stored as a JSON string on the wire to match Parse Server's `_Audience.query` column type, so saving an audience with a `Hash` query no longer fails the server schema check. The public API is unchanged — assign a `Hash`, read a `Hash` back. See [CHANGELOG.md](./CHANGELOG.md)
14
+ - **5.4.0 — Faster AtlasSearch role-cache expiry** — `Parse::AtlasSearch` `role_cache_ttl` now defaults to 30 seconds (was 120) so a role grant or revoke is reflected in `$search` ACL decisions sooner, at the cost of slightly more frequent role lookups. See [CHANGELOG.md](./CHANGELOG.md)
15
+
16
+ ### What's new in 5.3
17
+
18
+ - **5.3.0 — Run webhook handlers (and clients) as the calling user** — Parse Server embeds the caller's live session token in every trigger webhook fired by a logged-in user. A handler can now opt in to acting on the server *as that user* — full ACL/CLP/`protectedFields` enforcement, no master key. `payload.session_token` exposes the captured token (`nil` for master-key requests; still scrubbed from `payload.user`/`payload.object`/`as_json`/logs); `payload.user_agent` returns a client-mode `Parse::Agent`, and `payload.user_client` a non-master `Parse::Client` with the token **bound** so even raw REST calls authorize as the user. The same user-scoped client is available client-side via `Parse::User#session_client` and the `Parse::Client#become(token)` primitive, with `Parse::Client#with_session { … }` for block scoping. Backed by a new `Parse::Client.new(session_token:)` option. See [Acting as the calling user](#acting-as-the-calling-user)
19
+ - **5.3.0 — Pluralized class-name aliases** — referencing the plural form of a model constant now resolves to that class, so `Posts.where(:author.eq => user).count` works for a class `Post`. The alias is created lazily on first reference and is the *same class object*, so every class method (`query`/`where`, `count`, `find`, `all`, scopes) works through it and `Posts.parse_class` still returns `"Post"`. Because it is the same class it adds no `Parse::Object.descendants` entry and never registers a separate Parse schema class. Classes whose name already ends in `s` are skipped by the automatic path; non-Parse plurals and typos fall through to a normal `NameError`. On by default — opt out with `Parse.pluralized_aliases = false` (or `PARSE_PLURALIZED_ALIASES=false`). For a custom plural, an `s`-ending class, or a namespaced model, call `pluralized_alias!` in the class body. See [Pluralized class-name aliases](#pluralized-class-name-aliases)
20
+ - **5.3.0 — afterSave create reports changed fields; force_ssl-consistent file equality** — a trigger handler that keys off dirty tracking now sees every field on an `afterSave` *create*, symmetric with `afterSave` updates: the built object marks each populated data property changed (from `nil`) while `createdAt`/`updatedAt`/`ACL`/`objectId` stay clean and object readability, `new?`, and `existed?` are unchanged — so a handler that builds a payload from `*_changed?` / `changes` works uniformly across create and update. Separately, `Parse::File#==` now compares both files through the canonical `url` reader, so two files at the same location compare equal regardless of `Parse::File.force_ssl` (and `a == b` matches `b == a`), and a re-signed URL for the same object no longer reads as a change. See [Cloud Code Triggers](#cloud-code-triggers)
21
+
7
22
  ### What's new in 5.2
8
23
 
9
24
  - **5.2.1 — Webhook triggers receive the full Parse object** — trigger handlers (`beforeSave`/`afterSave`/…) now get the complete server object (`createdAt`/`updatedAt`, `ACL`, internal fields); only live credentials (session tokens, password hashes) are stripped. `Parse::Object#existed?` / `#new?` are reliable in `afterSave`, `afterSave` updates carry dirty tracking, and the model lifecycle runs in ActiveModel order — `before_save → before_create` then `after_create → after_save` — so `before_create` now fires for REST/JS/Auth0 creates (and `after_save` no longer double-fires). See [Cloud Code Triggers](#cloud-code-triggers)
@@ -203,9 +218,20 @@ result = Parse.call_function :myFunctionName, {param: value}
203
218
 
204
219
  ```
205
220
 
221
+ ## Examples
222
+
223
+ Runnable, self-contained scripts live in [`examples/`](examples/) — see
224
+ [`examples/README.md`](examples/README.md) for the full index. Highlights:
225
+
226
+ - [`basic_server.rb`](examples/basic_server.rb) — master-key setup: models, schema, CRUD + queries.
227
+ - [`basic_client.rb`](examples/basic_client.rb) — unprivileged client with row-level ACL enforcement.
228
+ - [`live_query_listener.rb`](examples/live_query_listener.rb) — interactive LiveQuery console scoped to a user's session.
229
+ - [`rag_chatbot.rb`](examples/rag_chatbot.rb) — retrieval-augmented generation with `semantic_search` + an OpenAI/Anthropic add-in.
230
+ - [`transaction_example.rb`](examples/transaction_example.rb) — atomic multi-object transactions.
231
+
206
232
  ## Release History
207
233
 
208
- **Current version: 5.0.1** | **Ruby 3.2+ required**
234
+ **Current version: 5.4.0** | **Ruby 3.2+ required**
209
235
 
210
236
  The 5.0 highlights (vector search / RAG, pooled Redis cache, AS::N instrumentation, MCP transport hardening, GraphQL type generation) are summarized in the [What's new in 5.0](#whats-new-in-50) section above. Earlier releases are recorded below.
211
237
 
@@ -296,6 +322,7 @@ The 1.x line is the original [`modernistik/parse-stack`](https://github.com/mode
296
322
  - [Linking and Unlinking](#linking-and-unlinking)
297
323
  - [Request Password Reset](#request-password-reset)
298
324
  - [Modeling and Subclassing](#modeling-and-subclassing)
325
+ - [Pluralized class-name aliases](#pluralized-class-name-aliases)
299
326
  - [Defining Properties](#defining-properties)
300
327
  - [Accessor Aliasing](#accessor-aliasing)
301
328
  - [Property Options](#property-options)
@@ -1579,8 +1606,11 @@ user.mfa_status # => :enabled, :disabled, or :unknown
1579
1606
  # Disable MFA (requires current token)
1580
1607
  user.disable_mfa!(current_token: "123456")
1581
1608
 
1582
- # Admin reset (master key) — authorized_by must be a Parse::User
1583
- user.disable_mfa_master_key!(authorized_by: admin_user)
1609
+ # Admin reset (master key) — fails closed: pass either an admin_role:
1610
+ # for the library to verify, or allow_unverified: true to assert that
1611
+ # you have already authorized the operator out-of-band.
1612
+ user.disable_mfa_master_key!(authorized_by: admin_user, admin_role: "Admin")
1613
+ # or: user.disable_mfa_master_key!(authorized_by: admin_user, allow_unverified: true)
1584
1614
  ```
1585
1615
 
1586
1616
  **SMS MFA (requires Parse Server SMS callback):**
@@ -1622,6 +1652,61 @@ class Commentary < Parse::Object
1622
1652
  end
1623
1653
  ```
1624
1654
 
1655
+ ### Pluralized class-name aliases
1656
+
1657
+ As a convenience, the plural form of a model constant resolves to that class, so the plural reads naturally at a query call site:
1658
+
1659
+ ```ruby
1660
+ class Post < Parse::Object
1661
+ property :title, :string
1662
+ belongs_to :author, as: :user
1663
+ end
1664
+
1665
+ # `Posts` resolves to `Post` on first reference:
1666
+ Posts.where(:author.eq => current_user).count
1667
+ Posts.query(:title.exists => true).results
1668
+ Posts.find("abc123")
1669
+ ```
1670
+
1671
+ The alias is created lazily (via `const_missing`) the first time the plural constant is referenced, and it is the **same class object** as the singular — `Posts.equal?(Post)` is `true`. As a result:
1672
+
1673
+ - Every class method works through it for free: `query`/`where`, `count`, `find`, `all`, and any `scope` you define.
1674
+ - `Posts.parse_class` still returns `"Post"`. The alias is purely a Ruby-constant convenience.
1675
+ - It adds no `Parse::Object.descendants` entry and **never registers a separate Parse schema class** — schema introspection and `Parse.auto_upgrade!` see exactly one class.
1676
+
1677
+ The automatic path is deliberately conservative:
1678
+
1679
+ - A class whose name already ends in `s` (for example `Status`, `Series`) is **skipped**, since its plural is ambiguous.
1680
+ - A plural that does not singularize to a known `Parse::Object` subclass (a typo, or `Strings` → `String`) falls through to a normal `NameError` — the SDK does not change Ruby's behavior for non-model constants.
1681
+
1682
+ The feature is enabled by default. Opt out globally:
1683
+
1684
+ ```ruby
1685
+ Parse.pluralized_aliases = false # programmatic
1686
+ # or
1687
+ # PARSE_PLURALIZED_ALIASES=false # environment
1688
+ ```
1689
+
1690
+ For a custom plural, a class whose name ends in `s` that you *do* want aliased, or a namespaced model (where the alias should live on the enclosing module rather than at the top level), declare it explicitly with the `pluralized_alias!` macro. The explicit macro ignores the global flag and the `s`-ending guard:
1691
+
1692
+ ```ruby
1693
+ class Status < Parse::Object
1694
+ pluralized_alias! :Statuses # defines ::Statuses => Status
1695
+ end
1696
+
1697
+ module Blog
1698
+ class Article < Parse::Object
1699
+ pluralized_alias! # defines Blog::Articles => Blog::Article
1700
+ end
1701
+ end
1702
+ ```
1703
+
1704
+ If the target constant already exists and is not the class itself, `pluralized_alias!` raises `ArgumentError` rather than clobbering it (a code reloader swapping the class object is detected and the alias is re-pointed instead).
1705
+
1706
+ The automatic path and the macro differ in *where* the alias constant is installed. The macro always anchors it on the class's own parent — top level for `Post`, the enclosing module for a namespaced model. The automatic path installs it on the module where the plural was first referenced (Ruby's `const_missing` fires on the referencing lexical scope), so a bare `Posts` written inside `module Blog` defines `Blog::Posts`. Both resolve to the same class; if you want a single, predictable top-level constant, prefer the macro.
1707
+
1708
+ > **Note on reloading:** under a code reloader (for example Zeitwerk in Rails development), a model class is swapped for a fresh object on reload, but the alias constant the SDK created is not tracked by the reloader and is therefore not removed. The `pluralized_alias!` macro detects this on the next run of the class body and re-points the alias to the current class. An *automatically*-created alias, however, stays bound to the previous class object until the process restarts (`const_missing` cannot re-fire for a constant that is still defined). If you depend on the plural during development and want fully deterministic reload behavior, declare it explicitly with `pluralized_alias!`, or disable the feature with `Parse.pluralized_aliases = false`.
1709
+
1625
1710
  ### Defining Properties
1626
1711
  Properties are considered a literal-type of association. This means that a defined local property maps directly to a column name for that remote Parse class which contain the value. **All properties are implicitly formatted to map to a lower-first camelcase version in Parse (remote).** Therefore a local property defined as `like_count`, would be mapped to the remote column of `likeCount` automatically. The only special behavior to this rule is the `:id` property which maps to `objectId` in Parse. This implicit conversion mapping is the default behavior, but can be changed on a per-property basis. All Parse data types are supported and all Parse::Object subclasses already provide definitions for `:id` (objectId), `:created_at` (createdAt), `:updated_at` (updatedAt) and `:acl` (ACL) properties.
1627
1712
 
@@ -4855,6 +4940,32 @@ The `parse_object` handed to your handler is the **full object as Parse Server s
4855
4940
 
4856
4941
  For any `after_*` hook, return values are not needed since Parse does not utilize them. You may also register as many `after_save` or `after_delete` handlers as you prefer, all of them will be called.
4857
4942
 
4943
+ For `before_save` (and functions), the handler's value **is** the response Parse Server acts on — return the (possibly mutated) `parse_object` to allow the write, or `false` / `error!` to reject it. You can set that value with an explicit `return` or as the block's last expression; both work, as do the proc idioms `next value` / `break value`:
4944
+
4945
+ ```ruby
4946
+ Parse::Webhooks.route :before_save, :Artist do
4947
+ artist = parse_object
4948
+ return artist if artist.name.present? # explicit early return
4949
+ error! "name is required" # raise to reject the save
4950
+ end
4951
+ ```
4952
+
4953
+ `self` inside the block is the `Parse::Webhooks::Payload`, so `parse_object`, `params`, and `error!` are available directly. As anywhere in Ruby, `return` ends the handler immediately — to run work *after* the response is sent, use `after_response` (below) rather than code after the `return`.
4954
+
4955
+ #### Deferring work until after the response
4956
+
4957
+ `payload.after_response { … }` (alias `defer`) registers work to run **after** the webhook response has been sent, off the critical path of the save or function the client is waiting on. The handler returns its value synchronously; the deferred block runs afterward — ideal for search indexing, cache warming, or fan-out that should not add latency.
4958
+
4959
+ ```ruby
4960
+ Parse::Webhooks.route :after_save, :Post do
4961
+ post = parse_object
4962
+ after_response { SearchIndex.reindex(post.id) } # runs after the reply is sent
4963
+ post
4964
+ end
4965
+ ```
4966
+
4967
+ Under Puma or Unicorn the block runs via `rack.after_reply` once the response is flushed (same worker thread, zero added round-trip latency); on a server without it (e.g. WEBrick) it falls back to a detached thread. Multiple blocks run in order and are isolated — one raising affects neither the response nor the others. Notes: deferred blocks run **only on the success path** (a rejected `before_save` runs none), "after the response" is **not** "after the row commits" (don't rely on the persisted row inside the block), and the work is **in-process and best-effort** — it dies with the worker, so for anything that *must* happen use a durable job queue (Sidekiq / ActiveJob). Blocks are drained only when the payload runs through the mounted `Parse::Webhooks` Rack app (a no-op under direct `run_function` / `call_route`). See the [Cloud Code Webhooks Guide](docs/webhooks_guide.md#deferring-work-until-after-the-response).
4968
+
4858
4969
  > **Your model's `after_save` callbacks run here too.** When an `after_save` /
4859
4970
  > `after_create` trigger fires, the webhook rebuilds the `Parse::Object` from the
4860
4971
  > payload and runs that model's ActiveModel `after_save` / `after_create`
@@ -4866,6 +4977,57 @@ For any `after_*` hook, return values are not needed since Parse does not utiliz
4866
4977
  > for saves from other clients (JS / iOS / REST), the webhook runs them, since
4867
4978
  > the SDK never had the chance.
4868
4979
 
4980
+ #### ActiveModel callbacks vs. Parse Server triggers
4981
+
4982
+ The SDK exposes the full ActiveModel lifecycle on every model
4983
+ (`before_validation`, `before_save`/`after_save`, `before_create`/`after_create`,
4984
+ `before_update`/`after_update`, `before_destroy`/`after_destroy`). Parse Server,
4985
+ separately, exposes a fixed set of **webhook trigger types**. They are not
4986
+ one-to-one — the SDK maps between them, and a webhook must be **registered** for
4987
+ your ActiveModel logic to run server-side for non-Ruby clients (JS / iOS / REST /
4988
+ Dashboard). Without a registered webhook, that logic runs only in the Ruby
4989
+ process that initiated the save.
4990
+
4991
+ Supported Parse Server trigger types: `beforeSave`/`afterSave`,
4992
+ `beforeDelete`/`afterDelete`, `beforeFind`/`afterFind`, `beforeLogin`/`afterLogin`,
4993
+ `afterLogout`, `beforePasswordResetRequest`, `beforeConnect`,
4994
+ `beforeSubscribe`/`afterEvent`, and file triggers on the `@File` pseudo-class.
4995
+
4996
+ The **authentication** triggers (`beforeLogin`/`afterLogin`/`afterLogout`/
4997
+ `beforePasswordResetRequest`) and **LiveQuery** triggers (`beforeConnect`/
4998
+ `beforeSubscribe`/`afterEvent`) route as first-class shapes — predicates
4999
+ (`before_login?` … `after_event?`, `auth_trigger?`/`live_query_trigger?`), an
5000
+ `event` accessor, and top-level `sessionToken` capture into `payload.session_token`.
5001
+ None of them run ActiveModel `save`/`create`/`destroy` callbacks, even though the
5002
+ auth triggers carry a `_User`/`_Session`. Parse Server **ignores the response body**
5003
+ for all of them, so the only signal that affects the operation is rejection, and
5004
+ only on the `before*` variants: returning `false` (or calling `error!`) from a
5005
+ `before_login`/`before_connect`/`before_subscribe`/`before_password_reset_request`
5006
+ handler denies the operation, while anything else is a success no-op. (LiveQuery
5007
+ triggers are delivered over HTTP only in a co-located single-process setup;
5008
+ `beforeConnect` is effectively in-process-only.)
5009
+
5010
+ Key relationship — **`beforeSave`/`afterSave` carry the create variants**. Parse
5011
+ Server has **no `beforeCreate`/`afterCreate` trigger** (it rejects them). The SDK
5012
+ runs your `before_create`/`after_create` callbacks *inside* the
5013
+ `beforeSave`/`afterSave` handler for new objects, in ActiveModel order
5014
+ (`before_save → before_create`, `after_create → after_save`). So **registering a
5015
+ `beforeSave` webhook enables both `before_save` and `before_create`**, and
5016
+ `afterSave` enables both `after_save` and `after_create`. Requesting a create
5017
+ webhook raises with guidance pointing you at the save trigger.
5018
+
5019
+ > **`after_save` is synchronous and on the critical path.** Parse Server waits
5020
+ > for the webhook to return before completing the client's write — even on
5021
+ > `afterSave`, whose return value is a no-op. Treat `after_save` as a place to
5022
+ > **enqueue** background work, not to run long logic inline, and avoid saving
5023
+ > other objects inside it (each cascading save fires more webhooks). `beforeSave`
5024
+ > can mutate or reject the write, so it is necessarily inline — keep it lean.
5025
+
5026
+ For the full picture — trigger types, registration, the synchronous-latency
5027
+ model, the Ruby-initiated dedup, and inbound replay/freshness protection — see
5028
+ the [Cloud Code Webhooks Guide](docs/webhooks_guide.md) and
5029
+ [`examples/webhook_server.rb`](examples/webhook_server.rb).
5030
+
4869
5031
  #### Trigger object state
4870
5032
 
4871
5033
  Because the trigger payload is server-authoritative, the `parse_object` your
@@ -4930,6 +5092,80 @@ double-firing. `:if`/`:unless` conditions on these callbacks are honored.
4930
5092
  > most for client-initiated saves, where the callback runs inside the webhook —
4931
5093
  > Ruby-SDK saves run it in-process after their own REST response instead.
4932
5094
 
5095
+ #### Acting as the calling user
5096
+
5097
+ Parse Server includes the caller's live session token (`user.sessionToken`) in
5098
+ every trigger webhook fired by a logged-in user (it is absent for a master-key
5099
+ request). A handler can opt in to acting on the server **as that user** —
5100
+ authorized by Parse Server with full ACL, CLP, and `protectedFields`
5101
+ enforcement — instead of reaching for the application master key. Three opt-in
5102
+ handles are available on the `payload`:
5103
+
5104
+ | Handle | Returns | `nil` when |
5105
+ |---|---|---|
5106
+ | `payload.session_token` | the caller's raw token (`String`) | master-key request (no user) |
5107
+ | `payload.user_agent(**opts)` | a non-master `Parse::Agent` in **client mode**, token bound | no token |
5108
+ | `payload.user_client` | a non-master `Parse::Client` with the token **bound** | no token |
5109
+
5110
+ ```ruby
5111
+ Parse::Webhooks.route :after_save, :Post do
5112
+ next true unless session_token? # master-key save → no caller token
5113
+
5114
+ # A client-mode Parse::Agent scoped to the caller (read tools only unless
5115
+ # you pass allow_mutations: true). ACL/CLP enforced; no master-key fallback.
5116
+ visible = user_agent.execute(:query_class, class_name: "Post", limit: 20)
5117
+
5118
+ # …or a raw user-scoped Parse::Client. The token is BOUND, so plain REST
5119
+ # calls authorize as the user with no per-call session_token: argument:
5120
+ mine = user_client.request(:get, "classes/Post").result
5121
+ true
5122
+ end
5123
+ ```
5124
+
5125
+ The token is captured before the payload's credential scrubbing runs, so it is
5126
+ still removed from `payload.user` / `payload.object` and never appears in
5127
+ `payload.as_json`, `payload.inspect`, or the request log. `user_client` binds it
5128
+ via the `Parse::Client.new(session_token:)` option, applied as the
5129
+ lowest-priority auth fallback — an explicit per-call `session_token:`, a
5130
+ `Parse.with_session` block, or an explicit `use_master_key: true` all still take
5131
+ precedence. This applies to **webhook-delivered** triggers (including the model
5132
+ `webhook :before_save` DSL, which is HTTP-delivered); a genuine in-process
5133
+ ActiveModel `before_save :method` callback has no incoming request and therefore
5134
+ no caller token.
5135
+
5136
+ The same user-scoped client is available on the **client side**. Two shapes:
5137
+
5138
+ ```ruby
5139
+ # 1. A client object you can pass around (carries the user's token + the
5140
+ # server connection, no master key):
5141
+ client = Parse::User.login(username, password).session_client
5142
+ Parse::Query.new("Post", client: client).results # runs as the user
5143
+ # (Parse.client.become(token) builds the same thing from any token.)
5144
+
5145
+ # 2. A block that runs ordinary model operations as the user, without
5146
+ # threading session_token: through each call:
5147
+ Parse::User.login(username, password).with_session do
5148
+ Post.query.count # counts only the user's readable Posts
5149
+ Post.create(title: "Hi") # created under the user's permissions
5150
+ end
5151
+ ```
5152
+
5153
+ `with_session` (on a `Parse::User`, a `Parse::Client`, or `Parse.with_session`)
5154
+ scopes by binding the token as the ambient session — it authorizes
5155
+ **REST-routed** operations (`find` / `get` / `count` / `save`) as the user. It
5156
+ does **not** scope mongo-direct queries (`results_direct`, `aggregate`, Atlas
5157
+ search): those resolve auth from the query's own `session_token:` / `acl_user:`
5158
+ and otherwise run in **master** mode, so scope them explicitly with a per-query
5159
+ `session_token:` or a scoped `Parse::Agent`. (To run a query as a user *without*
5160
+ a token — via the master key and SDK-side ACL simulation — use
5161
+ `Parse::Query#scope_to_user(user)`.) `Parse::Client#anonymous` builds the same
5162
+ non-master client with no token, for an explicitly unauthenticated request.
5163
+
5164
+ To explore a server as a specific user from this repo, `rake client:console`
5165
+ opens an IRB session whose default client is a non-master client bound to a user
5166
+ (session token, login, or anonymous), so every query in the session runs with
5167
+ that user's ACL/CLP — with `whoami` and `as_master { … }` helpers.
5168
+
4933
5169
  `before_save` and `before_delete` hooks have special functionality and multiple ways to halt operations:
4934
5170
 
4935
5171
  1. **Using `error!` method**: Calling `error!` will return an error response to Parse Server
@@ -5628,6 +5864,13 @@ The integration tests use Docker Compose to spin up a Parse Server instance with
5628
5864
  - Docker and Docker Compose installed
5629
5865
  - Ruby environment with bundler
5630
5866
 
5867
+ > **Always run the suite with `bundle exec`.** Newer `minitest` (6.0+) moved
5868
+ > `minitest/mock` out into a separate gem, so a bare `ruby`/`rake` invocation
5869
+ > activates minitest 6 and then fails to load `minitest/mock`, aborting every
5870
+ > test at load time with `cannot load such file -- minitest/mock (LoadError)`.
5871
+ > Running through bundler pins the locked versions and avoids this. If you hit
5872
+ > that LoadError, prefix the command with `bundle exec`.
5873
+
5631
5874
  #### Setup and Running Tests
5632
5875
 
5633
5876
  1. **Enable Docker Tests**: Set the environment variable to enable Docker-based tests:
@@ -5712,6 +5955,56 @@ docker compose -f scripts/docker/docker-compose.test.yml up -d
5712
5955
  curl -s http://localhost:29337/parse/health # -> {"status":"ok"}
5713
5956
  ```
5714
5957
 
5958
+ #### Network Exposure and the Preflight Guard
5959
+
5960
+ Every service binds to loopback (`127.0.0.1`) by default, and the default
5961
+ credentials above are committed to this repository — safe in combination,
5962
+ since nothing off the host can reach them. Each bind is overridable
5963
+ (`PARSE_BIND`, `MONGO_BIND`, `REDIS_BIND`, `DASHBOARD_BIND`) for the
5964
+ occasional need to attach a remote client while debugging.
5965
+
5966
+ That override is a footgun: pointing a bind at `0.0.0.0` while the default
5967
+ credentials are still in force would publish an admin-credentialed stack
5968
+ (Mongo `admin:password`, master key `psnextItMasterKey`) onto your LAN. A
5969
+ `preflight` service runs before anything else and **refuses to start the
5970
+ stack** in exactly that case. To proceed, do one of:
5971
+
5972
+ ```bash
5973
+ # 1. Keep it loopback (the default) — just omit the *_BIND override.
5974
+
5975
+ # 2. Supply real credentials instead of the committed test defaults.
5976
+ PARSE_MASTER_KEY="$(openssl rand -hex 24)" \
5977
+ MONGO_ROOT_PASSWORD="$(openssl rand -hex 24)" \
5978
+ MONGO_BIND=0.0.0.0 \
5979
+ docker compose -f scripts/docker/docker-compose.test.yml up -d
5980
+
5981
+ # 3. Acknowledge the exposure on a trusted, isolated network.
5982
+ ALLOW_INSECURE_BIND=1 MONGO_BIND=0.0.0.0 \
5983
+ docker compose -f scripts/docker/docker-compose.test.yml up -d
5984
+ ```
5985
+
5986
+ #### Secret Injection (real credentials)
5987
+
5988
+ The committed defaults are deliberately non-secret, so the loopback stack
5989
+ needs no secrets manager. If you point the stack at *real or shared*
5990
+ credentials (option 2 above, or a staging Mongo), keep them out of your
5991
+ shell history and the compose file by injecting them at launch. The stack
5992
+ reads plain environment variables, so any injector works:
5993
+
5994
+ ```bash
5995
+ # 1Password CLI — secrets resolved from an op:// .env reference file.
5996
+ op run --env-file=.env.secrets -- \
5997
+ docker compose -f scripts/docker/docker-compose.test.yml up -d
5998
+
5999
+ # Doppler — secrets pulled from a configured project/config.
6000
+ doppler run -- \
6001
+ docker compose -f scripts/docker/docker-compose.test.yml up -d
6002
+ ```
6003
+
6004
+ Use the committed `.env.sample` as the reference for which variables each
6005
+ side expects; copy it to a gitignored `.env` (or an `op://`-referenced
6006
+ `.env.secrets`) and fill in real values there.
6007
+
5715
6008
  #### Environment Variables
5716
6009
 
5717
6010
  The defaults above are baked into the Compose file and the test helpers, so the