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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25238eea832d32b8c536522ec1c43d087b3db8e996b13d39430c2edfd74481ee
4
- data.tar.gz: 806f28241cfbfe7e77fa230bd2e6b2b4aa071b89b7dda828f52d0b5182a7122c
3
+ metadata.gz: 02b1de621aaff6ee9a80804b8cb961f18887ef1f6b0a45a38d4c7baa1861f9f7
4
+ data.tar.gz: d4ae4c20a3e7694d7162e091f5ec3a7dcff05399e352eceecb8dae2dc214c274
5
5
  SHA512:
6
- metadata.gz: b85adcba96ac6023989bf42e8676eee9bb22b4ef0cb02073cc7ed0b79078c3edd88a84ff6f0b6c6f15eda214ba6a9c3165d35c7c250ef1ecfcd0471f58c3929c
7
- data.tar.gz: 9646a8b0f4bcd679a373d8a3ba189f0f160a7227280690b2ca165987b535f0c0ac6972dcf04d053ba441b732f80462698d7e1e08365c4419368aeea4481117c6
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- parse-stack-next (5.2.0)
4
+ parse-stack-next (5.2.1)
5
5
  activemodel (>= 6.1, < 9)
6
6
  activesupport (>= 6.1, < 9)
7
7
  connection_pool (>= 2.2, < 4)
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. In `:before_save` triggers, this object already contains dirty tracking information of what has been changed.
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
- - **Session id is a bearer capability.** The listening stream authenticates via
519
- the agent factory and keys delivery off the server-issued `Mcp-Session-Id`,
520
- which the client must keep secret possession of a valid session id (plus a
521
- valid agent) is sufficient to attach. This matches the cancellation model.
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
- # Runs all the registered `before_save` related callbacks.
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
- # With terminator configured, run_callbacks will return false if any callback returns false
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.
@@ -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
 
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.2.0"
9
+ VERSION = "5.2.1"
10
10
  end
11
11
  end
@@ -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
- # Strip protected mass-assignment keys (sessionToken, _rperm, _wperm,
82
- # _hashed_password, authData, roles, etc.) BEFORE constructing the
83
- # user object. Without this, an attacker reaching the webhook
84
- # endpoint with a valid key (or with the optional unauthenticated
85
- # mode enabled) can forge any of these fields on +payload.user+
86
- # via the +objectId+-present hydration branch that bypasses the
87
- # +Parse::Object#apply_attributes!+ protected-key filter.
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
- @user = Parse::User.new(self.class.scrub_protected_keys(hash[:user]))
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.scrub_protected_keys(hash[:object])
106
+ @object = self.class.scrub_credentials(hash[:object])
96
107
  @trigger_name = hash[:trigger_name]
97
- @original = self.class.scrub_protected_keys(hash[:original])
98
- @update = self.class.scrub_protected_keys(hash[:update]) || {}
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
- # Routing metadata that must be preserved on payload hashes even
107
- # though the general mass-assignment denylist forbids it. Stripping
108
- # +className+ here breaks +parse_class+/+parse_object+ resolution and
109
- # silently disables +payload_class_mismatch?+. The denylist still
110
- # protects +Parse::Object#apply_attributes!+ at hydration time.
111
- PAYLOAD_PRESERVED_KEYS = %w[className __type].freeze
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 the +PROTECTED_MASS_ASSIGNMENT_KEYS+
115
- # removed, except for routing metadata in +PAYLOAD_PRESERVED_KEYS+.
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.scrub_protected_keys(obj)
138
+ def self.scrub_credentials(obj)
120
139
  return obj unless obj.is_a?(Hash)
121
- denied = Parse::Properties::PROTECTED_MASS_ASSIGNMENT_KEYS
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
 
@@ -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
- prepare_result = result.prepare_save!
237
- # If prepare_save! returns false (callback chain was halted), throw an error
238
- if prepare_result == false
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.2.0
4
+ version: 5.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin