parse-stack-next 5.2.0 → 5.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -0
- data/Gemfile.lock +1 -1
- data/README.md +59 -1
- data/docs/mcp_guide.md +75 -4
- data/lib/parse/model/core/actions.rb +7 -9
- data/lib/parse/model/object.rb +41 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/webhooks/payload.rb +62 -34
- data/lib/parse/webhooks.rb +15 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02b1de621aaff6ee9a80804b8cb961f18887ef1f6b0a45a38d4c7baa1861f9f7
|
|
4
|
+
data.tar.gz: d4ae4c20a3e7694d7162e091f5ec3a7dcff05399e352eceecb8dae2dc214c274
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0cfcf6a2d6472788bc88b000acf11e81fd87b2cead5cae3f7b8e0021ad94172a1051d62cfc3e16fe3dd40086a2dd55f2a4aafa3b4abfdd443a9f4eec273b5656
|
|
7
|
+
data.tar.gz: 0bf1c5f83855416fd99473e69876c1f6906f2d6cefcc4891f1a67ab60ce92154483c0a49a31a0b0cbed2891a2cd976b0d7f7436f308633272ed2fc0c75dd0871
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
## parse-stack-next Changelog
|
|
2
2
|
|
|
3
|
+
### 5.2.1
|
|
4
|
+
|
|
5
|
+
#### Webhook trigger handlers now receive the full Parse object
|
|
6
|
+
|
|
7
|
+
Webhook trigger payloads (`beforeSave`/`afterSave`/`beforeDelete`/`afterDelete`)
|
|
8
|
+
are delivered by Parse Server and authenticated by the webhook key, so they are
|
|
9
|
+
trusted, server-authoritative state. Previously the payload was filtered through
|
|
10
|
+
the wide mass-assignment denylist, which stripped server-issued
|
|
11
|
+
`createdAt`/`updatedAt` (and other non-credential fields) before the handler
|
|
12
|
+
could see them. That broke `Parse::Object#existed?` and `#new?` inside
|
|
13
|
+
`afterSave` handlers — `existed?` always returned `false` and `new?` always
|
|
14
|
+
returned `true`, regardless of whether the object was created or updated — and
|
|
15
|
+
hid the object's timestamps and ACL from handler code.
|
|
16
|
+
|
|
17
|
+
- **FIXED**: `afterSave`/`beforeSave` handlers now receive the full object as
|
|
18
|
+
Parse Server sends it (`createdAt`, `updatedAt`, `ACL`, internal fields).
|
|
19
|
+
`Parse::Object#existed?` and `#new?` are now reliable inside `afterSave`
|
|
20
|
+
handlers. (Genuine credentials — session tokens and password hashes — are
|
|
21
|
+
still stripped, and `Parse::User` continues to protect `authData` on
|
|
22
|
+
`payload.user`.)
|
|
23
|
+
- **NEW**: `afterSave` handlers on an updated object now carry dirty tracking
|
|
24
|
+
relative to the prior state, so `title_changed?`, `changed`, and `changes`
|
|
25
|
+
work inside `afterSave` the same way they already did inside `beforeSave`.
|
|
26
|
+
- **CHANGED**: Inbound webhook trigger payloads are now scrubbed of genuine
|
|
27
|
+
credential material only (`sessionToken`, `_hashed_password`,
|
|
28
|
+
`_password_history`) rather than the full mass-assignment denylist. Protection
|
|
29
|
+
against persisting forged privileged fields remains on the write path: a save
|
|
30
|
+
emits only declared, dirty-tracked properties, and an after-trigger response
|
|
31
|
+
is `true`/`false`, so forged `_rperm`/`_wperm`/`authData` cannot be persisted
|
|
32
|
+
through a handler. This applies only to the inbound webhook trigger payload;
|
|
33
|
+
client login/signup responses are unaffected and still return session tokens.
|
|
34
|
+
- **CHANGED**: In an `afterSave` handler, `new?` now correctly returns `false`
|
|
35
|
+
(the object is already persisted) where the previous timestamp-stripping bug
|
|
36
|
+
made it return `true`. Use `existed?` to distinguish create from update inside
|
|
37
|
+
`afterSave` (`existed? == false` for a create, `true` for an update); `new?`
|
|
38
|
+
is intended for `beforeSave`.
|
|
39
|
+
- **CHANGED**: Dirty-gated `after_save` side effects now fire on client/REST-
|
|
40
|
+
initiated saves where they previously silently no-op'd. With timestamps and
|
|
41
|
+
dirty tracking restored, a callback such as `after_save { notify if
|
|
42
|
+
title_changed? }` will now activate for objects created or updated via REST /
|
|
43
|
+
JS cloud code, not only for Ruby-model saves.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
Parse::Webhooks.route :after_save, "Post" do
|
|
47
|
+
post = parse_object
|
|
48
|
+
|
|
49
|
+
if post.existed? # now reliable: false on create, true on update
|
|
50
|
+
NotificationService.changed(post) if post.title_changed?
|
|
51
|
+
else
|
|
52
|
+
post.create_default_associations!
|
|
53
|
+
end
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### Lifecycle callbacks run in ActiveModel order for client-initiated saves
|
|
59
|
+
|
|
60
|
+
Parse Server exposes no separate `beforeCreate`/`afterCreate` triggers — only
|
|
61
|
+
`beforeSave` and `afterSave`. The webhook layer now runs the model lifecycle
|
|
62
|
+
callbacks for a client-initiated create in the canonical ActiveModel order:
|
|
63
|
+
`before_save` → `before_create` (in the `beforeSave` webhook) then
|
|
64
|
+
`after_create` → `after_save` (in the `afterSave` webhook).
|
|
65
|
+
|
|
66
|
+
- **FIXED**: `before_create` callbacks now run for client/REST/JS/Auth0-created
|
|
67
|
+
objects. The `beforeSave` webhook runs `before_create` after `before_save` for
|
|
68
|
+
new objects (an object with no `original`); previously `before_create` never
|
|
69
|
+
fired for non-Ruby creates, so create-time setup written as `before_create`
|
|
70
|
+
was silently skipped.
|
|
71
|
+
- **FIXED**: `after_save` no longer double-fires on client-initiated saves. The
|
|
72
|
+
`beforeSave` webhook entry point previously ran the full save callback chain,
|
|
73
|
+
firing `after_save` during `beforeSave` in addition to the `afterSave`
|
|
74
|
+
webhook. It now runs the before phase only.
|
|
75
|
+
- **NEW**: `Parse::Object#run_before_save_callbacks` and
|
|
76
|
+
`#run_before_create_callbacks` — the before-phase counterparts to the existing
|
|
77
|
+
`run_after_save_callbacks` / `run_after_create_callbacks`.
|
|
78
|
+
- **CHANGED**: `Parse::Object#prepare_save!` is retained as a back-compat alias
|
|
79
|
+
for `run_before_save_callbacks` and now runs the before phase only (it no
|
|
80
|
+
longer also fires `after_save`). The before-phase runners honor `:if`/`:unless`
|
|
81
|
+
callback conditions and the callback terminator.
|
|
82
|
+
- **NOTE**: the webhook layer runs `before_save`/`before_create` and
|
|
83
|
+
`after_create`/`after_save`, but not `before_update`/`after_update` — those
|
|
84
|
+
`:update`-specific callbacks fire only on Ruby-model saves, not for
|
|
85
|
+
client-initiated (REST/JS/Auth0) saves. Use `before_save`/`after_save` (which
|
|
86
|
+
run for every save) and branch on `existed?` if you need update-only logic.
|
|
87
|
+
|
|
3
88
|
### 5.2.0
|
|
4
89
|
|
|
5
90
|
#### Retrieval layer — `Parse::Retrieval` (`Parse::RAG`)
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -6,6 +6,7 @@ A full-featured Ruby client SDK for [Parse Server](http://parseplatform.org/). [
|
|
|
6
6
|
|
|
7
7
|
### What's new in 5.2
|
|
8
8
|
|
|
9
|
+
- **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)
|
|
9
10
|
- **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
|
|
10
11
|
- **`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
|
|
11
12
|
- **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
|
|
@@ -397,6 +398,7 @@ The 1.x line is the original [`modernistik/parse-stack`](https://github.com/mode
|
|
|
397
398
|
- [Cloud Code Webhooks](#cloud-code-webhooks)
|
|
398
399
|
- [Cloud Code Functions](#cloud-code-functions)
|
|
399
400
|
- [Cloud Code Triggers](#cloud-code-triggers)
|
|
401
|
+
- [Trigger object state](#trigger-object-state)
|
|
400
402
|
- [Mounting Webhooks Application](#mounting-webhooks-application)
|
|
401
403
|
- [Register Webhooks](#register-webhooks)
|
|
402
404
|
- [Parse REST API Client](#parse-rest-api-client)
|
|
@@ -4825,7 +4827,9 @@ end
|
|
|
4825
4827
|
```
|
|
4826
4828
|
|
|
4827
4829
|
### Cloud Code Triggers
|
|
4828
|
-
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.
|
|
4830
|
+
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.
|
|
4831
|
+
|
|
4832
|
+
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.
|
|
4829
4833
|
|
|
4830
4834
|
```ruby
|
|
4831
4835
|
# recommended way
|
|
@@ -4862,6 +4866,60 @@ For any `after_*` hook, return values are not needed since Parse does not utiliz
|
|
|
4862
4866
|
> for saves from other clients (JS / iOS / REST), the webhook runs them, since
|
|
4863
4867
|
> the SDK never had the chance.
|
|
4864
4868
|
|
|
4869
|
+
#### Trigger object state
|
|
4870
|
+
|
|
4871
|
+
Because the trigger payload is server-authoritative, the `parse_object` your
|
|
4872
|
+
handler receives is the complete object, and the usual `Parse::Object`
|
|
4873
|
+
introspection works inside the trigger:
|
|
4874
|
+
|
|
4875
|
+
| What you want to know | In `:before_save` | In `:after_save` |
|
|
4876
|
+
|---|---|---|
|
|
4877
|
+
| Is this a create or an update? | `parse_object.new?` (`true` = create) | `parse_object.existed?` (`false` = create) or `payload.original.nil?` |
|
|
4878
|
+
| What changed? | `name_changed?`, `changes`, `changed` | `name_changed?`, `changes`, `changed` (relative to the prior state) |
|
|
4879
|
+
| Server timestamps | not yet assigned (`new?` create) | `created_at` / `updated_at` populated |
|
|
4880
|
+
| The prior stored values | `payload.original_parse_object` | `payload.original_parse_object` |
|
|
4881
|
+
|
|
4882
|
+
Use `new?` in `:before_save` and `existed?` in `:after_save`. In `:after_save`
|
|
4883
|
+
the object is already persisted, so `new?` is `false` for both creates and
|
|
4884
|
+
updates — `existed?` (`created_at != updated_at`) is the create/update signal,
|
|
4885
|
+
equivalently `payload.original.nil?`.
|
|
4886
|
+
|
|
4887
|
+
```ruby
|
|
4888
|
+
Parse::Webhooks.route :after_save, :Post do
|
|
4889
|
+
post = parse_object
|
|
4890
|
+
if post.existed?
|
|
4891
|
+
Search.reindex(post) if post.title_changed? # update
|
|
4892
|
+
else
|
|
4893
|
+
post.create_default_associations! # first save
|
|
4894
|
+
end
|
|
4895
|
+
true
|
|
4896
|
+
end
|
|
4897
|
+
```
|
|
4898
|
+
|
|
4899
|
+
**Lifecycle callback order.** Parse Server has no separate `beforeCreate` /
|
|
4900
|
+
`afterCreate` triggers — only `beforeSave` and `afterSave`. The SDK runs your
|
|
4901
|
+
model's ActiveModel callbacks in canonical order across the two webhooks:
|
|
4902
|
+
|
|
4903
|
+
```
|
|
4904
|
+
beforeSave webhook : before_save → before_create (before_create only for new objects)
|
|
4905
|
+
[Parse Server persists]
|
|
4906
|
+
afterSave webhook : after_create → after_save (after_create only for new objects)
|
|
4907
|
+
```
|
|
4908
|
+
|
|
4909
|
+
So a model `before_create` / `after_create` callback runs for objects created by
|
|
4910
|
+
**any** client (REST / JS cloud code / Auth0 / iOS), not just Ruby-model saves —
|
|
4911
|
+
provided the corresponding trigger is registered with Parse Server (see
|
|
4912
|
+
[Register Webhooks](#register-webhooks)). These callbacks fire **once** per save;
|
|
4913
|
+
Ruby-SDK-initiated saves run them locally and the webhook skips them to avoid
|
|
4914
|
+
double-firing. `:if`/`:unless` conditions on these callbacks are honored.
|
|
4915
|
+
|
|
4916
|
+
> **`before_update` / `after_update` do not run from webhooks.** The webhook
|
|
4917
|
+
> layer runs `before_save` / `before_create` / `after_create` / `after_save`
|
|
4918
|
+
> only. The `:update`-specific callbacks fire on Ruby-model saves but **not**
|
|
4919
|
+
> for client-initiated (REST / JS / Auth0) saves, because Parse Server has no
|
|
4920
|
+
> `beforeUpdate` / `afterUpdate` trigger. For update-time logic that must run
|
|
4921
|
+
> for all clients, use `before_save` / `after_save` and branch on `existed?`.
|
|
4922
|
+
|
|
4865
4923
|
> **Keep `after_save` handlers fast.** Parse Server **waits** for the `after_save`
|
|
4866
4924
|
> webhook response before returning to the saving client (only LiveQuery events
|
|
4867
4925
|
> are truly fire-and-forget), so a slow handler adds latency to that client's
|
data/docs/mcp_guide.md
CHANGED
|
@@ -515,10 +515,14 @@ Parse Server version and its `masterKeyIps` configuration.)
|
|
|
515
515
|
- **Subscriptions do not survive a listening-stream reconnect.** Closing the
|
|
516
516
|
`GET` stream tears down the session's LiveQuery subscriptions; a client that
|
|
517
517
|
reconnects must re-issue its `resources/subscribe` calls.
|
|
518
|
-
- **
|
|
519
|
-
the agent factory and
|
|
520
|
-
|
|
521
|
-
|
|
518
|
+
- **Listening streams are owner-bound (not a bare bearer capability).** The
|
|
519
|
+
stream authenticates via the agent factory *and* the server-issued
|
|
520
|
+
`Mcp-Session-Id` is bound to the principal that established it, so another
|
|
521
|
+
authenticated caller who knows or guesses the id is refused with `403`. The
|
|
522
|
+
`Mcp-Session-Id` is still secret-bearing and should be kept confidential, but
|
|
523
|
+
possession alone is no longer sufficient — see **Listening-stream ownership**
|
|
524
|
+
below for the binding model, its limits, and the `principal_resolver:` knob
|
|
525
|
+
master-key deployments need to make it effective.
|
|
522
526
|
- **Per-session and global caps.** A client that subscribes but never opens (or
|
|
523
527
|
later drops) its listening stream leaves LiveQuery subscriptions running until
|
|
524
528
|
the session is torn down. A per-session ceiling (default 100,
|
|
@@ -538,6 +542,73 @@ Parse Server version and its `masterKeyIps` configuration.)
|
|
|
538
542
|
one-time warning at construction when a streaming or subscription/notification
|
|
539
543
|
surface is enabled without a cap.
|
|
540
544
|
|
|
545
|
+
### Listening-stream ownership
|
|
546
|
+
|
|
547
|
+
The GET listening stream is the single server→client bus shared by resource
|
|
548
|
+
subscriptions, [server-initiated notifications](#server-initiated-notifications-general-purpose),
|
|
549
|
+
and [approval elicitation](#approval-workflows-mcp-elicitation). Whoever holds
|
|
550
|
+
that stream receives everything pushed to its `Mcp-Session-Id` — another
|
|
551
|
+
session's `notifications/resources/updated`, `elicitation/create` approval
|
|
552
|
+
prompts, and arbitrary `notify` payloads. So the stream is **owner-bound**: a
|
|
553
|
+
session is tied to the principal that established it, and only the same
|
|
554
|
+
principal may later open (or re-open) its stream.
|
|
555
|
+
|
|
556
|
+
How the binding is established and checked:
|
|
557
|
+
|
|
558
|
+
- **Initialize-bound.** A session created through an `initialize` POST is bound
|
|
559
|
+
authoritatively to that caller's principal. A later `GET` carrying the same
|
|
560
|
+
`Mcp-Session-Id` from a *different* principal is refused with HTTP `403`
|
|
561
|
+
(`-32600`, "Mcp-Session-Id is owned by another principal"). A re-`initialize`
|
|
562
|
+
by the same caller refreshes the binding.
|
|
563
|
+
- **Trust-on-first-use (TOFU) for the decoupled bus.** A session id that
|
|
564
|
+
`initialize` never saw — the `notifications: true` bus, where application code
|
|
565
|
+
pushes to ids it chose itself — is claimed by the first principal to attach a
|
|
566
|
+
listener; a different principal attaching afterward is refused. TOFU closes
|
|
567
|
+
the prior model's eviction-after-claim hole (a second caller could overwrite
|
|
568
|
+
or shadow an existing listener), but a first-mover attacker can still claim an
|
|
569
|
+
*unused* id, so **notification-bus session ids must be high-entropy**.
|
|
570
|
+
- **Stream close keeps the claim.** The binding is dropped only on an explicit
|
|
571
|
+
`DELETE` termination, not on mere stream close — a reconnecting owner keeps
|
|
572
|
+
its claim, and an attacker cannot grab the id during a brief disconnect.
|
|
573
|
+
|
|
574
|
+
The principal fingerprint is derived, in order, from: an operator-supplied
|
|
575
|
+
`principal_resolver:`, then the agent's `session_token` (hashed), then
|
|
576
|
+
`acl_user`, then `acl_role`. With none of these the agent falls back to a shared
|
|
577
|
+
`"mk"` (master-key) principal:
|
|
578
|
+
|
|
579
|
+
- **A master-key-everywhere factory makes owner-binding a no-op.** If every
|
|
580
|
+
request builds a bare master-key agent (no `session_token:` / `acl_user:` /
|
|
581
|
+
`acl_role:`), all agents share the `"mk"` fingerprint and are
|
|
582
|
+
indistinguishable, so the `403` never fires among them. Deployments that
|
|
583
|
+
authenticate users upstream and run master-key agents should supply a
|
|
584
|
+
`principal_resolver:` to restore a real per-user identity:
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
app = Parse::Agent::MCPRackApp.new(
|
|
588
|
+
streaming: true,
|
|
589
|
+
notifications: true, # or resource_subscriptions: true
|
|
590
|
+
principal_resolver: ->(agent, env) {
|
|
591
|
+
# Return a stable per-user id (String). nil/empty falls through to the
|
|
592
|
+
# agent's own scope, then to the shared "mk" principal.
|
|
593
|
+
env["myapp.authenticated_user_id"]
|
|
594
|
+
},
|
|
595
|
+
agent_factory: ->(env) { ... },
|
|
596
|
+
)
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
The resolver must respond to `#call`; an invalid one raises `ArgumentError` at
|
|
600
|
+
construction. Per-user impersonation (binding a real `session_token` per
|
|
601
|
+
request) achieves the same effect without a resolver.
|
|
602
|
+
|
|
603
|
+
**Limits (same scope as the cancellation registry):** the owner registry is
|
|
604
|
+
per-`MCPRackApp` instance and **single-process** — it does not span Puma workers
|
|
605
|
+
or survive a restart. In a clustered deployment the `initialize` POST and the
|
|
606
|
+
`GET` stream may land on different workers, so the initialize-binding degrades
|
|
607
|
+
to TOFU there. The registry is LRU-bounded (default 10,000 sessions) so a stream
|
|
608
|
+
of `initialize`-without-`DELETE` sessions cannot grow it without limit; evicting
|
|
609
|
+
an active owner just downgrades that id to TOFU on its next attach. Blank
|
|
610
|
+
session ids or blank fingerprints fail closed.
|
|
611
|
+
|
|
541
612
|
---
|
|
542
613
|
|
|
543
614
|
## Approval Workflows (MCP elicitation)
|
|
@@ -1196,16 +1196,14 @@ module Parse
|
|
|
1196
1196
|
success
|
|
1197
1197
|
end
|
|
1198
1198
|
|
|
1199
|
-
#
|
|
1199
|
+
# Back-compat alias for {Parse::Object#run_before_save_callbacks}. The
|
|
1200
|
+
# canonical name spells out exactly what runs (the before_save callbacks,
|
|
1201
|
+
# before phase only) and is symmetric with run_after_save_callbacks /
|
|
1202
|
+
# run_before_create_callbacks / run_after_create_callbacks. Retained so
|
|
1203
|
+
# existing callers of `prepare_save!` keep working.
|
|
1204
|
+
# @return [Boolean] false if a before_save callback halted the chain, else true.
|
|
1200
1205
|
def prepare_save!
|
|
1201
|
-
|
|
1202
|
-
# We track if the block executes to know if callbacks were halted
|
|
1203
|
-
callback_success = false
|
|
1204
|
-
run_callbacks(:save) do
|
|
1205
|
-
callback_success = true
|
|
1206
|
-
true
|
|
1207
|
-
end
|
|
1208
|
-
callback_success
|
|
1206
|
+
run_before_save_callbacks
|
|
1209
1207
|
end
|
|
1210
1208
|
|
|
1211
1209
|
# @return [Hash] a hash of the list of changes made to this instance.
|
data/lib/parse/model/object.rb
CHANGED
|
@@ -1645,6 +1645,25 @@ module Parse
|
|
|
1645
1645
|
run_callbacks_from_list(self.class._destroy_callbacks, :after)
|
|
1646
1646
|
end
|
|
1647
1647
|
|
|
1648
|
+
# Run before_save callbacks for this object (BEFORE phase only). Used by the
|
|
1649
|
+
# beforeSave webhook. Honors :if/:unless conditions and the callback
|
|
1650
|
+
# terminator: returns false if a callback halts the chain. The after_*
|
|
1651
|
+
# callbacks are NOT run here -- they belong to the afterSave webhook.
|
|
1652
|
+
# @return [Boolean] false if a before_save callback halted the chain, else true.
|
|
1653
|
+
def run_before_save_callbacks
|
|
1654
|
+
run_before_phase_callbacks(:save)
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
# Run before_create callbacks for this object (BEFORE phase only). Parse
|
|
1658
|
+
# Server exposes no separate beforeCreate trigger, so the beforeSave webhook
|
|
1659
|
+
# runs these for new objects right after before_save -- matching ActiveModel
|
|
1660
|
+
# order, where before_save wraps before_create. Honors :if/:unless and the
|
|
1661
|
+
# terminator.
|
|
1662
|
+
# @return [Boolean] false if a before_create callback halted the chain, else true.
|
|
1663
|
+
def run_before_create_callbacks
|
|
1664
|
+
run_before_phase_callbacks(:create)
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1648
1667
|
# Returns a hash of all the changes that have been made to the object. By default
|
|
1649
1668
|
# changes to the Parse::Properties::BASE_KEYS are ignored unless you pass true as
|
|
1650
1669
|
# an argument.
|
|
@@ -2054,6 +2073,28 @@ module Parse
|
|
|
2054
2073
|
end
|
|
2055
2074
|
true
|
|
2056
2075
|
end
|
|
2076
|
+
|
|
2077
|
+
# Runs ONLY the before-phase callbacks of an ActiveModel callback chain
|
|
2078
|
+
# (`:save` or `:create`), fully honoring `:if`/`:unless` conditions and the
|
|
2079
|
+
# model's terminator, WITHOUT running the after-phase callbacks. ActiveModel
|
|
2080
|
+
# `run_callbacks(kind) { block }` runs before -> block -> after; we throw out
|
|
2081
|
+
# of the block so the after callbacks never run, while the before callbacks
|
|
2082
|
+
# get complete condition/terminator handling. If a before callback halts the
|
|
2083
|
+
# chain (returns false), the block never executes, so `completed` stays false
|
|
2084
|
+
# and we report the halt. Used by the webhook before-phase so it does not
|
|
2085
|
+
# double-fire after_* (those belong to the afterSave webhook).
|
|
2086
|
+
# @return [Boolean] false if the chain was halted by a before callback.
|
|
2087
|
+
def run_before_phase_callbacks(kind)
|
|
2088
|
+
tag = :"__parse_before_phase_#{kind}"
|
|
2089
|
+
completed = false
|
|
2090
|
+
catch(tag) do
|
|
2091
|
+
run_callbacks(kind) do
|
|
2092
|
+
completed = true
|
|
2093
|
+
throw tag
|
|
2094
|
+
end
|
|
2095
|
+
end
|
|
2096
|
+
completed
|
|
2097
|
+
end
|
|
2057
2098
|
end
|
|
2058
2099
|
end
|
|
2059
2100
|
|
data/lib/parse/stack/version.rb
CHANGED
|
@@ -78,24 +78,35 @@ module Parse
|
|
|
78
78
|
hash = Hash[hash.map { |k, v| [k.to_s.underscore.to_sym, v] }]
|
|
79
79
|
@raw = hash
|
|
80
80
|
@master = hash[:master]
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
81
|
+
# Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
|
|
82
|
+
# Parse Server and, when a webhook key is configured (the default; see
|
|
83
|
+
# Parse::Webhooks.allow_unauthenticated for the opt-out used in tests /
|
|
84
|
+
# local dev), authenticated by it -- so they are treated as trusted,
|
|
85
|
+
# server-authoritative state. A handler is meant to receive the full
|
|
86
|
+
# object -- createdAt/updatedAt, ACL, internal fields and all. The only
|
|
87
|
+
# thing stripped here is genuine credential material a handler never
|
|
88
|
+
# legitimately needs to read (live session tokens, offline-crackable
|
|
89
|
+
# password hashes); see WEBHOOK_TRIGGER_CREDENTIAL_KEYS. Protection
|
|
90
|
+
# against *persisting* forged privileged fields lives on the write path
|
|
91
|
+
# (changes_payload emits only declared, dirty-tracked properties), not on
|
|
92
|
+
# this read path.
|
|
88
93
|
if hash[:user].present?
|
|
89
|
-
|
|
94
|
+
# Trusted hydration via .build (not .new) so server-sent timestamps and
|
|
95
|
+
# data fields remain readable; credentials are removed first. Note
|
|
96
|
+
# Parse::User applies its own protections, so `payload.user.auth_data`
|
|
97
|
+
# is not exposed here. The built object is pristine, so a handler that
|
|
98
|
+
# saves payload.user transmits nothing (no dirty changes) and cannot
|
|
99
|
+
# persist forgeries.
|
|
100
|
+
@user = Parse::User.build(self.class.scrub_credentials(hash[:user]))
|
|
90
101
|
end
|
|
91
102
|
@installation_id = hash[:installation_id]
|
|
92
103
|
@params = hash[:params]
|
|
93
104
|
@params = @params.with_indifferent_access if @params.is_a?(Hash)
|
|
94
105
|
@function_name = hash[:function_name]
|
|
95
|
-
@object = self.class.
|
|
106
|
+
@object = self.class.scrub_credentials(hash[:object])
|
|
96
107
|
@trigger_name = hash[:trigger_name]
|
|
97
|
-
@original = self.class.
|
|
98
|
-
@update = self.class.
|
|
108
|
+
@original = self.class.scrub_credentials(hash[:original])
|
|
109
|
+
@update = self.class.scrub_credentials(hash[:update]) || {}
|
|
99
110
|
# Added for beforeFind and afterFind triggers
|
|
100
111
|
@query = hash[:query]
|
|
101
112
|
@objects = hash[:objects] || []
|
|
@@ -103,25 +114,32 @@ module Parse
|
|
|
103
114
|
end
|
|
104
115
|
|
|
105
116
|
# @!visibility private
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
117
|
+
# Genuine credential material that is stripped from every webhook trigger
|
|
118
|
+
# payload before a handler can see it, even though the rest of the
|
|
119
|
+
# (trusted, server-authoritative) payload passes through untouched. A
|
|
120
|
+
# session token is a live bearer credential; a password hash is
|
|
121
|
+
# offline-crackable. A handler has no legitimate reason to read either,
|
|
122
|
+
# and removing them keeps them out of logs and out of any object a handler
|
|
123
|
+
# might persist. Everything else Parse Server sends -- createdAt/updatedAt,
|
|
124
|
+
# ACL, authData, roles, _rperm/_wperm, internal fields -- is preserved so
|
|
125
|
+
# the handler observes the full object. Write-side protection
|
|
126
|
+
# (changes_payload emits only declared, dirty-tracked properties) is what
|
|
127
|
+
# prevents persisting forged privileged fields.
|
|
128
|
+
WEBHOOK_TRIGGER_CREDENTIAL_KEYS = %w[
|
|
129
|
+
sessionToken session_token
|
|
130
|
+
_hashed_password _password_history
|
|
131
|
+
].freeze
|
|
112
132
|
|
|
113
133
|
# @!visibility private
|
|
114
|
-
# Returns a copy of +obj+ with
|
|
115
|
-
# removed
|
|
116
|
-
# Operates on string and symbol keys (Parse Server uses camelCase
|
|
134
|
+
# Returns a copy of +obj+ with only +WEBHOOK_TRIGGER_CREDENTIAL_KEYS+
|
|
135
|
+
# removed. Operates on string and symbol keys (Parse Server uses camelCase
|
|
117
136
|
# strings on the wire; downstream code may have already symbolized).
|
|
118
137
|
# Pass-through for non-Hash input.
|
|
119
|
-
def self.
|
|
138
|
+
def self.scrub_credentials(obj)
|
|
120
139
|
return obj unless obj.is_a?(Hash)
|
|
121
|
-
denied =
|
|
140
|
+
denied = WEBHOOK_TRIGGER_CREDENTIAL_KEYS
|
|
122
141
|
obj.reject do |k, _|
|
|
123
142
|
name = k.to_s
|
|
124
|
-
next false if PAYLOAD_PRESERVED_KEYS.include?(name)
|
|
125
143
|
denied.include?(name) || denied.include?(name.underscore)
|
|
126
144
|
end
|
|
127
145
|
end
|
|
@@ -278,24 +296,34 @@ module Parse
|
|
|
278
296
|
if @original.present? && @original.is_a?(Hash)
|
|
279
297
|
o = Parse::Object.build @original, parse_class
|
|
280
298
|
o.apply_attributes! @object, dirty_track: true
|
|
281
|
-
|
|
282
|
-
if o.is_a?(Parse::User) && @update.present? && @update["authData"].present?
|
|
283
|
-
o.auth_data = @update["authData"]
|
|
284
|
-
end
|
|
285
299
|
return o
|
|
286
300
|
else #else the object must be new
|
|
287
301
|
klass = Parse::Object.find_class parse_class
|
|
288
302
|
# if we have a class, return that with updated changes, otherwise
|
|
289
303
|
# default to regular object
|
|
290
|
-
if klass.present?
|
|
291
|
-
o = klass.new(@object || {})
|
|
292
|
-
if o.is_a?(Parse::User) && @update.present? && @update["authData"].present?
|
|
293
|
-
o.auth_data = @update["authData"]
|
|
294
|
-
end
|
|
295
|
-
return o
|
|
296
|
-
end # if klass.present?
|
|
304
|
+
return klass.new(@object || {}) if klass.present?
|
|
297
305
|
end # if we have original
|
|
298
306
|
end # if before_trigger?
|
|
307
|
+
|
|
308
|
+
# afterSave on an UPDATE: build the prior state, then overlay the final
|
|
309
|
+
# state with dirty tracking so `*_changed?` / `changes` work inside
|
|
310
|
+
# afterSave handlers (symmetric with the beforeSave path above). The
|
|
311
|
+
# filter uses the timestamp-preserving INITIALIZE key set rather than the
|
|
312
|
+
# wide mass-assignment set: the wide set would strip the incoming
|
|
313
|
+
# `updatedAt` from the overlay, leaving the prior `updatedAt` and breaking
|
|
314
|
+
# `existed?`. The diff still excludes credentials / _rperm / _wperm /
|
|
315
|
+
# authData / roles, and an after-trigger response is only true/false, so
|
|
316
|
+
# there is no path for a forged privileged field to be persisted.
|
|
317
|
+
if after_save? && @original.present? && @original.is_a?(Hash)
|
|
318
|
+
o = Parse::Object.build @original, parse_class
|
|
319
|
+
o.apply_attributes! @object, dirty_track: true,
|
|
320
|
+
protected_set: Parse::Properties::PROTECTED_INITIALIZE_KEYS
|
|
321
|
+
return o
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# afterSave on a CREATE (and every other trigger): the full object as the
|
|
325
|
+
# server sent it. createdAt/updatedAt survive (only credentials are
|
|
326
|
+
# scrubbed), so `new?` / `existed?` read correctly.
|
|
299
327
|
Parse::Object.build(@object, parse_class)
|
|
300
328
|
end
|
|
301
329
|
|
data/lib/parse/webhooks.rb
CHANGED
|
@@ -233,11 +233,23 @@ module Parse
|
|
|
233
233
|
# ran ActiveModel before_save callbacks locally. A client-spoofed
|
|
234
234
|
# `_RB_` without master falls through and runs them here.
|
|
235
235
|
unless trusted_ruby_initiated
|
|
236
|
-
|
|
237
|
-
# If
|
|
238
|
-
if
|
|
236
|
+
before_save_result = result.run_before_save_callbacks
|
|
237
|
+
# If a before_save callback halted the chain (returned false), reject the save.
|
|
238
|
+
if before_save_result == false
|
|
239
239
|
raise Parse::Webhooks::ResponseError, "Save halted by before_save callback"
|
|
240
240
|
end
|
|
241
|
+
# Parse Server exposes no separate beforeCreate trigger, so the
|
|
242
|
+
# beforeSave hook is the single point at which before_create must
|
|
243
|
+
# run for a client-initiated create. Run it AFTER before_save, for
|
|
244
|
+
# new objects only -- matching ActiveModel order (before_save wraps
|
|
245
|
+
# before_create) and mirroring the afterSave hook, which runs
|
|
246
|
+
# after_create then after_save. `original.nil?` marks a create.
|
|
247
|
+
if payload && payload.original.nil?
|
|
248
|
+
create_result = result.run_before_create_callbacks
|
|
249
|
+
if create_result == false
|
|
250
|
+
raise Parse::Webhooks::ResponseError, "Save halted by before_create callback"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
241
253
|
end
|
|
242
254
|
# For before_save, return the changes payload (what Parse Server expects)
|
|
243
255
|
result = result.changes_payload
|