Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,90 @@
## parse-stack-next Changelog

### 5.2.1

#### Webhook trigger handlers now receive the full Parse object

Webhook trigger payloads (`beforeSave`/`afterSave`/`beforeDelete`/`afterDelete`)
are delivered by Parse Server and authenticated by the webhook key, so they are
trusted, server-authoritative state. Previously the payload was filtered through
the wide mass-assignment denylist, which stripped server-issued
`createdAt`/`updatedAt` (and other non-credential fields) before the handler
could see them. That broke `Parse::Object#existed?` and `#new?` inside
`afterSave` handlers — `existed?` always returned `false` and `new?` always
returned `true`, regardless of whether the object was created or updated — and
hid the object's timestamps and ACL from handler code.

- **FIXED**: `afterSave`/`beforeSave` handlers now receive the full object as
Parse Server sends it (`createdAt`, `updatedAt`, `ACL`, internal fields).
`Parse::Object#existed?` and `#new?` are now reliable inside `afterSave`
handlers. (Genuine credentials — session tokens and password hashes — are
still stripped, and `Parse::User` continues to protect `authData` on
`payload.user`.)
- **NEW**: `afterSave` handlers on an updated object now carry dirty tracking
relative to the prior state, so `title_changed?`, `changed`, and `changes`
work inside `afterSave` the same way they already did inside `beforeSave`.
- **CHANGED**: Inbound webhook trigger payloads are now scrubbed of genuine
credential material only (`sessionToken`, `_hashed_password`,
`_password_history`) rather than the full mass-assignment denylist. Protection
against persisting forged privileged fields remains on the write path: a save
emits only declared, dirty-tracked properties, and an after-trigger response
is `true`/`false`, so forged `_rperm`/`_wperm`/`authData` cannot be persisted
through a handler. This applies only to the inbound webhook trigger payload;
client login/signup responses are unaffected and still return session tokens.
- **CHANGED**: In an `afterSave` handler, `new?` now correctly returns `false`
(the object is already persisted) where the previous timestamp-stripping bug
made it return `true`. Use `existed?` to distinguish create from update inside
`afterSave` (`existed? == false` for a create, `true` for an update); `new?`
is intended for `beforeSave`.
- **CHANGED**: Dirty-gated `after_save` side effects now fire on client/REST-
initiated saves where they previously silently no-op'd. With timestamps and
dirty tracking restored, a callback such as `after_save { notify if
title_changed? }` will now activate for objects created or updated via REST /
JS cloud code, not only for Ruby-model saves.

```ruby
Parse::Webhooks.route :after_save, "Post" do
post = parse_object

if post.existed? # now reliable: false on create, true on update
NotificationService.changed(post) if post.title_changed?
else
post.create_default_associations!
end
true
end
```

#### Lifecycle callbacks run in ActiveModel order for client-initiated saves

Parse Server exposes no separate `beforeCreate`/`afterCreate` triggers — only
`beforeSave` and `afterSave`. The webhook layer now runs the model lifecycle
callbacks for a client-initiated create in the canonical ActiveModel order:
`before_save` → `before_create` (in the `beforeSave` webhook) then
`after_create` → `after_save` (in the `afterSave` webhook).

- **FIXED**: `before_create` callbacks now run for client/REST/JS/Auth0-created
objects. The `beforeSave` webhook runs `before_create` after `before_save` for
new objects (an object with no `original`); previously `before_create` never
fired for non-Ruby creates, so create-time setup written as `before_create`
was silently skipped.
- **FIXED**: `after_save` no longer double-fires on client-initiated saves. The
`beforeSave` webhook entry point previously ran the full save callback chain,
firing `after_save` during `beforeSave` in addition to the `afterSave`
webhook. It now runs the before phase only.
- **NEW**: `Parse::Object#run_before_save_callbacks` and
`#run_before_create_callbacks` — the before-phase counterparts to the existing
`run_after_save_callbacks` / `run_after_create_callbacks`.
- **CHANGED**: `Parse::Object#prepare_save!` is retained as a back-compat alias
for `run_before_save_callbacks` and now runs the before phase only (it no
longer also fires `after_save`). The before-phase runners honor `:if`/`:unless`
callback conditions and the callback terminator.
- **NOTE**: the webhook layer runs `before_save`/`before_create` and
`after_create`/`after_save`, but not `before_update`/`after_update` — those
`:update`-specific callbacks fire only on Ruby-model saves, not for
client-initiated (REST/JS/Auth0) saves. Use `before_save`/`after_save` (which
run for every save) and branch on `existed?` if you need update-only logic.

### 5.2.0

#### Retrieval layer — `Parse::Retrieval` (`Parse::RAG`)
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
parse-stack-next (5.2.0)
parse-stack-next (5.2.1)
activemodel (>= 6.1, < 9)
activesupport (>= 6.1, < 9)
connection_pool (>= 2.2, < 4)
Expand Down
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ A full-featured Ruby client SDK for [Parse Server](http://parseplatform.org/). [

### What's new in 5.2

- **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)
- **Retrieval layer — `Parse::Retrieval` (`Parse::RAG`)** — `Parse::Retrieval.retrieve(query:, klass:, k:, filter:, tenant_scope:, …)` embeds a natural-language query, runs Atlas `$vectorSearch` through the existing ACL-enforcing `find_similar`, and splits each retrieved document's text field into scored `Parse::Retrieval::Chunk`s. Chunking is presentation-only (embedding stays one-vector-per-record), via `Parse::Retrieval::Chunker::FixedSizeOverlap(size:, overlap:, by:, max_chunks_per_document:)` (subclass `Chunker::Base` for custom strategies). ACL is mongo-direct (no REST two-stage); tenant scope folds into the Atlas pre-filter
- **`semantic_search` agent tool + `agent_searchable`** — declare `agent_searchable field:, filter_fields:` on a model to expose it to the readonly, client-safe `semantic_search` tool. The handler enforces the full agent envelope: searchable-class allowlist, recursive underscore-key refusal + filter-field allowlist on input, `field_allowlist` projection plus tenant-scope re-assertion on output, and score quantization in non-admin contexts
- **MCP elicitation — human-in-the-loop approval** — opt in with `Parse::Agent.require_approval_for = [:write, :admin]` to require spec-native `elicitation/create` approval before destructive tool calls. A pluggable `agent.approval_gate` (reachable on the non-MCP path too) shows the dry-run diff and blocks on the client's reply; `call_method` resolves the *effective* tier from the target `agent_method`. Fails closed (no capability / no listening stream / non-streaming transport / timeout → refuse); replies are session-bound
Expand Down Expand Up @@ -397,6 +398,7 @@ The 1.x line is the original [`modernistik/parse-stack`](https://github.com/mode
- [Cloud Code Webhooks](#cloud-code-webhooks)
- [Cloud Code Functions](#cloud-code-functions)
- [Cloud Code Triggers](#cloud-code-triggers)
- [Trigger object state](#trigger-object-state)
- [Mounting Webhooks Application](#mounting-webhooks-application)
- [Register Webhooks](#register-webhooks)
- [Parse REST API Client](#parse-rest-api-client)
Expand Down Expand Up @@ -4825,7 +4827,9 @@ end
```

### Cloud Code Triggers
You can register webhooks to handle the different object triggers: `:before_save`, `:after_save`, `:before_delete` and `:after_delete`. The `payload` object, which is an instance of `Parse::Webhooks::Payload`, contains several properties that represent the payload. One of the most important ones is `parse_object`, which will provide you with the instance of your specific Parse object. In `:before_save` triggers, this object already contains dirty tracking information of what has been changed.
You can register webhooks to handle the different object triggers: `:before_save`, `:after_save`, `:before_delete` and `:after_delete`. The `payload` object, which is an instance of `Parse::Webhooks::Payload`, contains several properties that represent the payload. One of the most important ones is `parse_object`, which will provide you with the instance of your specific Parse object.

The `parse_object` handed to your handler is the **full object as Parse Server sent it** — `createdAt`/`updatedAt`, `ACL`, and internal fields all survive (only live credentials — session tokens and password hashes — are stripped; `Parse::User` additionally protects `authData` on `payload.user`). Both `:before_save` and `:after_save` objects carry **dirty tracking** of what changed (`name_changed?`, `changes`), and `Parse::Object#existed?` / `#new?` are reliable inside `:after_save`. See [Trigger object state](#trigger-object-state) below.
Comment on lines +4830 to +4832

```ruby
# recommended way
Expand Down Expand Up @@ -4862,6 +4866,60 @@ For any `after_*` hook, return values are not needed since Parse does not utiliz
> for saves from other clients (JS / iOS / REST), the webhook runs them, since
> the SDK never had the chance.

#### Trigger object state

Because the trigger payload is server-authoritative, the `parse_object` your
handler receives is the complete object, and the usual `Parse::Object`
introspection works inside the trigger:

| What you want to know | In `:before_save` | In `:after_save` |
|---|---|---|
| Is this a create or an update? | `parse_object.new?` (`true` = create) | `parse_object.existed?` (`false` = create) or `payload.original.nil?` |
| What changed? | `name_changed?`, `changes`, `changed` | `name_changed?`, `changes`, `changed` (relative to the prior state) |
| Server timestamps | not yet assigned (`new?` create) | `created_at` / `updated_at` populated |
| The prior stored values | `payload.original_parse_object` | `payload.original_parse_object` |

Use `new?` in `:before_save` and `existed?` in `:after_save`. In `:after_save`
the object is already persisted, so `new?` is `false` for both creates and
updates — `existed?` (`created_at != updated_at`) is the create/update signal,
equivalently `payload.original.nil?`.

```ruby
Parse::Webhooks.route :after_save, :Post do
post = parse_object
if post.existed?
Search.reindex(post) if post.title_changed? # update
else
post.create_default_associations! # first save
end
true
end
```

**Lifecycle callback order.** Parse Server has no separate `beforeCreate` /
`afterCreate` triggers — only `beforeSave` and `afterSave`. The SDK runs your
model's ActiveModel callbacks in canonical order across the two webhooks:

```
beforeSave webhook : before_save → before_create (before_create only for new objects)
[Parse Server persists]
afterSave webhook : after_create → after_save (after_create only for new objects)
```

So a model `before_create` / `after_create` callback runs for objects created by
**any** client (REST / JS cloud code / Auth0 / iOS), not just Ruby-model saves —
provided the corresponding trigger is registered with Parse Server (see
[Register Webhooks](#register-webhooks)). These callbacks fire **once** per save;
Ruby-SDK-initiated saves run them locally and the webhook skips them to avoid
double-firing. `:if`/`:unless` conditions on these callbacks are honored.

> **`before_update` / `after_update` do not run from webhooks.** The webhook
> layer runs `before_save` / `before_create` / `after_create` / `after_save`
> only. The `:update`-specific callbacks fire on Ruby-model saves but **not**
> for client-initiated (REST / JS / Auth0) saves, because Parse Server has no
> `beforeUpdate` / `afterUpdate` trigger. For update-time logic that must run
> for all clients, use `before_save` / `after_save` and branch on `existed?`.

> **Keep `after_save` handlers fast.** Parse Server **waits** for the `after_save`
> webhook response before returning to the saving client (only LiveQuery events
> are truly fire-and-forget), so a slow handler adds latency to that client's
Expand Down
79 changes: 75 additions & 4 deletions docs/mcp_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,10 +515,14 @@ Parse Server version and its `masterKeyIps` configuration.)
- **Subscriptions do not survive a listening-stream reconnect.** Closing the
`GET` stream tears down the session's LiveQuery subscriptions; a client that
reconnects must re-issue its `resources/subscribe` calls.
- **Session id is a bearer capability.** The listening stream authenticates via
the agent factory and keys delivery off the server-issued `Mcp-Session-Id`,
which the client must keep secret — possession of a valid session id (plus a
valid agent) is sufficient to attach. This matches the cancellation model.
- **Listening streams are owner-bound (not a bare bearer capability).** The
stream authenticates via the agent factory *and* the server-issued
`Mcp-Session-Id` is bound to the principal that established it, so another
authenticated caller who knows or guesses the id is refused with `403`. The
`Mcp-Session-Id` is still secret-bearing and should be kept confidential, but
possession alone is no longer sufficient — see **Listening-stream ownership**
below for the binding model, its limits, and the `principal_resolver:` knob
master-key deployments need to make it effective.
- **Per-session and global caps.** A client that subscribes but never opens (or
later drops) its listening stream leaves LiveQuery subscriptions running until
the session is torn down. A per-session ceiling (default 100,
Expand All @@ -538,6 +542,73 @@ Parse Server version and its `masterKeyIps` configuration.)
one-time warning at construction when a streaming or subscription/notification
surface is enabled without a cap.

### Listening-stream ownership

The GET listening stream is the single server→client bus shared by resource
subscriptions, [server-initiated notifications](#server-initiated-notifications-general-purpose),
and [approval elicitation](#approval-workflows-mcp-elicitation). Whoever holds
that stream receives everything pushed to its `Mcp-Session-Id` — another
session's `notifications/resources/updated`, `elicitation/create` approval
prompts, and arbitrary `notify` payloads. So the stream is **owner-bound**: a
session is tied to the principal that established it, and only the same
principal may later open (or re-open) its stream.

How the binding is established and checked:

- **Initialize-bound.** A session created through an `initialize` POST is bound
authoritatively to that caller's principal. A later `GET` carrying the same
`Mcp-Session-Id` from a *different* principal is refused with HTTP `403`
(`-32600`, "Mcp-Session-Id is owned by another principal"). A re-`initialize`
by the same caller refreshes the binding.
- **Trust-on-first-use (TOFU) for the decoupled bus.** A session id that
`initialize` never saw — the `notifications: true` bus, where application code
pushes to ids it chose itself — is claimed by the first principal to attach a
listener; a different principal attaching afterward is refused. TOFU closes
the prior model's eviction-after-claim hole (a second caller could overwrite
or shadow an existing listener), but a first-mover attacker can still claim an
*unused* id, so **notification-bus session ids must be high-entropy**.
- **Stream close keeps the claim.** The binding is dropped only on an explicit
`DELETE` termination, not on mere stream close — a reconnecting owner keeps
its claim, and an attacker cannot grab the id during a brief disconnect.

The principal fingerprint is derived, in order, from: an operator-supplied
`principal_resolver:`, then the agent's `session_token` (hashed), then
`acl_user`, then `acl_role`. With none of these the agent falls back to a shared
`"mk"` (master-key) principal:

- **A master-key-everywhere factory makes owner-binding a no-op.** If every
request builds a bare master-key agent (no `session_token:` / `acl_user:` /
`acl_role:`), all agents share the `"mk"` fingerprint and are
indistinguishable, so the `403` never fires among them. Deployments that
authenticate users upstream and run master-key agents should supply a
`principal_resolver:` to restore a real per-user identity:

```ruby
app = Parse::Agent::MCPRackApp.new(
streaming: true,
notifications: true, # or resource_subscriptions: true
principal_resolver: ->(agent, env) {
# Return a stable per-user id (String). nil/empty falls through to the
# agent's own scope, then to the shared "mk" principal.
env["myapp.authenticated_user_id"]
},
agent_factory: ->(env) { ... },
)
```

The resolver must respond to `#call`; an invalid one raises `ArgumentError` at
construction. Per-user impersonation (binding a real `session_token` per
request) achieves the same effect without a resolver.

**Limits (same scope as the cancellation registry):** the owner registry is
per-`MCPRackApp` instance and **single-process** — it does not span Puma workers
or survive a restart. In a clustered deployment the `initialize` POST and the
`GET` stream may land on different workers, so the initialize-binding degrades
to TOFU there. The registry is LRU-bounded (default 10,000 sessions) so a stream
of `initialize`-without-`DELETE` sessions cannot grow it without limit; evicting
an active owner just downgrades that id to TOFU on its next attach. Blank
session ids or blank fingerprints fail closed.

---

## Approval Workflows (MCP elicitation)
Expand Down
16 changes: 7 additions & 9 deletions lib/parse/model/core/actions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1196,16 +1196,14 @@ def destroy(session: nil)
success
end

# Runs all the registered `before_save` related callbacks.
# Back-compat alias for {Parse::Object#run_before_save_callbacks}. The
# canonical name spells out exactly what runs (the before_save callbacks,
# before phase only) and is symmetric with run_after_save_callbacks /
# run_before_create_callbacks / run_after_create_callbacks. Retained so
# existing callers of `prepare_save!` keep working.
# @return [Boolean] false if a before_save callback halted the chain, else true.
def prepare_save!
# With terminator configured, run_callbacks will return false if any callback returns false
# We track if the block executes to know if callbacks were halted
callback_success = false
run_callbacks(:save) do
callback_success = true
true
end
callback_success
run_before_save_callbacks
end

# @return [Hash] a hash of the list of changes made to this instance.
Expand Down
Loading
Loading