parse-stack-next 5.2.1 → 5.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02b1de621aaff6ee9a80804b8cb961f18887ef1f6b0a45a38d4c7baa1861f9f7
4
- data.tar.gz: d4ae4c20a3e7694d7162e091f5ec3a7dcff05399e352eceecb8dae2dc214c274
3
+ metadata.gz: 90a966c230e0a0e2e6fd814170726e3af713dae06c0341a00cd7581eecbe53ee
4
+ data.tar.gz: b171ed432c7ea7806ec07653b47122bd6102a16046c980d915fe45f98ffbca32
5
5
  SHA512:
6
- metadata.gz: 0cfcf6a2d6472788bc88b000acf11e81fd87b2cead5cae3f7b8e0021ad94172a1051d62cfc3e16fe3dd40086a2dd55f2a4aafa3b4abfdd443a9f4eec273b5656
7
- data.tar.gz: 0bf1c5f83855416fd99473e69876c1f6906f2d6cefcc4891f1a67ab60ce92154483c0a49a31a0b0cbed2891a2cd976b0d7f7436f308633272ed2fc0c75dd0871
6
+ metadata.gz: 510f3f2deb860cedc73f11455fb5d4789c327923abebad4e26b223ae9c385374ffa23bc9564561e7d4dddb31b0c1a015b7e7222dacd7c0b18bdb01199580d7a0
7
+ data.tar.gz: cd0fb4c706b3c33f12059c7431cf75abd792cbd5ba8b193dd43256aaa70c8fcdd3f86e10b3ad8ffb25c8e9ccc3f985a61bcaa8b49d24badf6f8358cec2b16f3d
data/.bundle/config CHANGED
@@ -1,4 +1,5 @@
1
1
  ---
2
2
  BUNDLE_FORCE_RUBY_PLATFORM: "true"
3
+ BUNDLE_FROZEN: "false"
3
4
  BUNDLE_PATH: "/home/runner/work/parse-stack-next/parse-stack-next/vendor/bundle"
4
5
  BUNDLE_DEPLOYMENT: "true"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,160 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.3.0
4
+
5
+ #### Run webhook handlers as the calling user
6
+
7
+ Parse Server includes the caller's live session token (`user.sessionToken`) in
8
+ every trigger webhook fired by a logged-in user. A handler can now opt in to
9
+ acting on the server *as that user* — with full ACL, CLP, and `protectedFields`
10
+ enforcement — instead of reaching for the application master key.
11
+
12
+ - **NEW**: `Parse::Webhooks::Payload#session_token` exposes the caller's session
13
+ token, captured from the incoming payload. It is `nil` for a master-key
14
+ request (which carries no user). The token is captured before the payload's
15
+ credential scrubbing runs, so it is still removed from `payload.user` /
16
+ `payload.object` and never appears in `payload.as_json` or the request log.
17
+ - **NEW**: `Parse::Webhooks::Payload#user_agent` returns a non-master
18
+ `Parse::Agent` bound to the caller's session token, so it runs in client mode
19
+ and every query is authorized as the user. Returns `nil` when there is no
20
+ token.
21
+ - **NEW**: `Parse::Webhooks::Payload#user_client` returns a non-master
22
+ `Parse::Client` that binds the caller's session token, so even raw REST calls
23
+ through it are authorized as the user with no further ceremony. Returns `nil`
24
+ when there is no token.
25
+ - **NEW**: `Parse::Client.new` accepts a `session_token:` option that binds a
26
+ token to the client. It is applied as the lowest-priority auth fallback on
27
+ every request (explicit per-call `session_token:` and `Parse.with_session`
28
+ still take precedence, and an explicit `use_master_key: true` skips it).
29
+ Pair it with `master_key: nil` to build a user-scoped client.
30
+ - **NEW**: `Parse::Client#become(session_token)` returns a new non-master client
31
+ that mirrors the receiver's connection settings (`server_url`/`app_id`/
32
+ `api_key`) but binds the given token — the primitive behind `payload.user_client`
33
+ and `Parse::User#session_client`. `Parse::Client#anonymous` returns the same
34
+ shape with no token (unauthenticated REST) to drop a bound identity.
35
+ - **NEW**: `Parse::User#session_client` returns a non-master `Parse::Client`
36
+ bound to a logged-in user's session token — the client-side counterpart of
37
+ `payload.user_client` (e.g. `Parse::User.login(u, p).session_client`).
38
+ - **NEW**: `Parse::Client#with_session { ... }` runs a block with the client's
39
+ bound session token active as the ambient session, so REST-routed operations
40
+ (`find`/`get`/`count`/`save`) inside it are authorized as the user without
41
+ passing `session_token:` per call (the client-receiver flavor of
42
+ `Parse.with_session` / `Parse::User#with_session`). Mongo-direct queries
43
+ (`results_direct`, `aggregate`, Atlas search) are unaffected and still require
44
+ explicit per-query scoping.
45
+ - **NEW**: `rake client:console` opens an interactive console whose default
46
+ client is a non-master client bound to a user (session token, login, or
47
+ anonymous), so every query in the session runs with that user's ACL / CLP /
48
+ `protectedFields` rather than the master key.
49
+ - **FIXED**: `Parse::Client#inspect` and `Parse::Webhooks::Payload#inspect` no
50
+ longer print the master key or session token (and, for the payload, the
51
+ pre-scrub raw credentials) in cleartext — they are redacted, so an error
52
+ reporter or stray `inspect` cannot leak them.
53
+ - **FIXED**: a whitespace-only `Parse::Client` `session_token:` is now treated
54
+ as no token, so it can never silently fall through to the master key.
55
+
56
+ #### Pluralized class-name aliases
57
+
58
+ Referencing the plural form of a model constant now resolves to that class, so
59
+ the plural reads naturally as a query entry point — `Posts.where(...).count`
60
+ works for a class `Post`.
61
+
62
+ - **NEW**: The plural alias is created lazily on first reference and is the
63
+ *same class object* as the singular (`Posts.equal?(Post)` is `true`), so every
64
+ class method works through it (`query`/`where`, `count`, `find`, `all`, and any
65
+ `scope`) and `Posts.parse_class` still returns `"Post"`. Because it is the same
66
+ class, it adds no `Parse::Object.descendants` entry and never registers a
67
+ separate Parse schema class.
68
+ - **NEW**: `pluralized_alias!` class macro for explicit opt-in — use it for a
69
+ custom plural, a class whose name ends in `s`, or a namespaced model (the alias
70
+ is defined on the enclosing module). It ignores the global flag and raises
71
+ `ArgumentError` rather than clobbering an existing unrelated constant.
72
+ - **NEW**: `Parse.pluralized_aliases` configuration (default `true`; opt out with
73
+ `Parse.pluralized_aliases = false` or `PARSE_PLURALIZED_ALIASES=false`).
74
+ - **CHANGED**: The automatic path is conservative — a class whose name already
75
+ ends in `s` is skipped, and a plural that does not singularize to a known
76
+ `Parse::Object` subclass falls through to a normal `NameError`.
77
+
78
+ #### Pointer associations declared with `property … as:` — BREAKING: now serialize as Pointers
79
+
80
+ Declaring a pointer with `property` instead of `belongs_to` used to fail
81
+ silently: the `as:` option was dropped and the field became a plain `:string`
82
+ column, so an assigned object was stored as a String and never serialized as a
83
+ Parse pointer. `property … as:` now resolves to the same pointer association as
84
+ `belongs_to`.
85
+
86
+ - **BREAKING**: a field declared with `property … as:` now serializes as a
87
+ Pointer (`{__type: "Pointer", …}`) instead of a String. If a deployed app
88
+ previously used `property … as:` and saved records, Parse Server pinned that
89
+ column as `String` on first write, and a Pointer write against it is rejected
90
+ with error 111 (schema type mismatch). Drop or migrate that column to a
91
+ Pointer type before deploying. Apps that never used `property … as:` are
92
+ unaffected — the option was a silent no-op, so no working pointer behavior
93
+ depended on it.
94
+ - **FIXED**: `property <name>, as: <class>` and `property <name>, :pointer` now
95
+ declare a pointer association identical to the equivalent `belongs_to` — same
96
+ `:pointer` field type, remote column, target-class reference, getter/setter,
97
+ dirty tracking, and `{__type: "Pointer", …}` wire form. Previously `as:` was
98
+ ignored and the field defaulted to `:string`, with no warning.
99
+ - **NEW**: `belongs_to` (and the delegating `property … as:`) raise an
100
+ `ArgumentError` at declaration time when `as:` names a scalar data type such
101
+ as `as: :string` — a scalar cannot be a pointer target. The message points to
102
+ the intended `property <name>, :<type>` form. The guard fires only on an
103
+ explicit `as:`, so existing `belongs_to :field` declarations are unaffected.
104
+ - **NEW**: `Parse.validate_associations!` verifies that every `belongs_to` /
105
+ `property … as:` pointer target and every `has_many` target — relation-,
106
+ array-, and query-backed — resolves to a known Parse class, reporting any
107
+ unresolved target as a `Class#field -> "Target"` offender. The query- and
108
+ array-backed `has_many` targets are the bucket where an `as:` typo otherwise
109
+ stays latent until the association is first traversed. Forward references are
110
+ legal at
111
+ declaration time and indistinguishable from typos there, so this cross-class
112
+ check is meant to run once after all models load (boot, CI, or a rake task).
113
+ - **IMPROVED**: `belongs_to` now stores `_description:` and `_enum:` agent
114
+ metadata, which previously only `property` retained. A documented pointer
115
+ association keeps its semantic description, including when declared through
116
+ `property … as:`.
117
+
118
+ #### afterSave create reports changed fields; file equality is force_ssl-consistent
119
+
120
+ A trigger handler that keys off dirty tracking (`*_changed?` / `changes`) now
121
+ sees every field an object was created with on an `afterSave` create, symmetric
122
+ with the existing `afterSave` update behavior, and two `Parse::File` values that
123
+ point at the same location compare equal regardless of `force_ssl`.
124
+
125
+ - **NEW**: On an `afterSave` trigger for a newly created object (no prior
126
+ persisted state), the built `Parse::Object` now marks every populated data
127
+ property as changed, so a handler can use `*_changed?` / `changes` / `changed`
128
+ uniformly across create and update. Previously the create object
129
+ was pristine, so `*_changed?` returned `false` for every field and a handler
130
+ that builds a payload from changed fields would see nothing on creates. A
131
+ property whose create value equals its declared `default:` (e.g.
132
+ `status: "draft"`, `count: 0`, `archived: false`) is correctly reported as
133
+ changed. System fields (`createdAt` / `updatedAt` / `ACL` / `objectId`) stay
134
+ clean — they are not reported as changed — and object readability, `new?`, and
135
+ `existed?` are unchanged. Credential and row-permission fields remain filtered
136
+ from the built object. The `afterSave` update path is unchanged.
137
+ - **CHANGED**: As a result of the above, model `after_save` / `after_create`
138
+ callbacks fired through the webhook dispatcher now observe an `afterSave`-create
139
+ object whose data fields are dirty (rather than pristine). A handler that
140
+ branched on dirty state for a created object should review that expectation;
141
+ the values themselves are unchanged.
142
+ - **FIXED**: `Parse::File#==` now compares both files through the canonical
143
+ `url` reader rather than the raw stored URL on one side, so the comparison is
144
+ symmetric (`a == b` matches `b == a`) and consistent when
145
+ `Parse::File.force_ssl` coerces a stored `http://` URL to `https://`. Two
146
+ files at the same location previously read as unequal under `force_ssl`, which
147
+ could spuriously mark a file property as changed during dirty tracking.
148
+ Signed-URL query parameters are still stripped before comparison, so a
149
+ re-signed URL for the same object compares equal while a different underlying
150
+ location does not.
151
+ - **NEW**: `Parse::File#content_signature` is the overridable seam that `==`
152
+ uses to decide whether two files refer to the same underlying file. It returns
153
+ the canonical `url` today (Parse Server's S3 files adapter exposes no content
154
+ digest); a files-adapter shim or `Parse::File` subclass can override it to key
155
+ equality off a content hash (S3 ETag / sha256) in the future without touching
156
+ dirty tracking.
157
+
3
158
  ### 5.2.1
4
159
 
5
160
  #### Webhook trigger handlers now receive the full Parse object
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.3.0)
5
5
  activemodel (>= 6.1, < 9)
6
6
  activesupport (>= 6.1, < 9)
7
7
  connection_pool (>= 2.2, < 4)
data/README.md CHANGED
@@ -4,6 +4,12 @@
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.3
8
+
9
+ - **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)
10
+ - **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)
11
+ - **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)
12
+
7
13
  ### What's new in 5.2
8
14
 
9
15
  - **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)
@@ -296,6 +302,7 @@ The 1.x line is the original [`modernistik/parse-stack`](https://github.com/mode
296
302
  - [Linking and Unlinking](#linking-and-unlinking)
297
303
  - [Request Password Reset](#request-password-reset)
298
304
  - [Modeling and Subclassing](#modeling-and-subclassing)
305
+ - [Pluralized class-name aliases](#pluralized-class-name-aliases)
299
306
  - [Defining Properties](#defining-properties)
300
307
  - [Accessor Aliasing](#accessor-aliasing)
301
308
  - [Property Options](#property-options)
@@ -1622,6 +1629,61 @@ class Commentary < Parse::Object
1622
1629
  end
1623
1630
  ```
1624
1631
 
1632
+ ### Pluralized class-name aliases
1633
+
1634
+ As a convenience, the plural form of a model constant resolves to that class, so the plural reads naturally at a query call site:
1635
+
1636
+ ```ruby
1637
+ class Post < Parse::Object
1638
+ property :title, :string
1639
+ belongs_to :author, as: :user
1640
+ end
1641
+
1642
+ # `Posts` resolves to `Post` on first reference:
1643
+ Posts.where(:author.eq => current_user).count
1644
+ Posts.query(:title.exists => true).results
1645
+ Posts.find("abc123")
1646
+ ```
1647
+
1648
+ 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:
1649
+
1650
+ - Every class method works through it for free: `query`/`where`, `count`, `find`, `all`, and any `scope` you define.
1651
+ - `Posts.parse_class` still returns `"Post"`. The alias is purely a Ruby-constant convenience.
1652
+ - 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.
1653
+
1654
+ The automatic path is deliberately conservative:
1655
+
1656
+ - A class whose name already ends in `s` (for example `Status`, `Series`) is **skipped**, since its plural is ambiguous.
1657
+ - 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.
1658
+
1659
+ The feature is enabled by default. Opt out globally:
1660
+
1661
+ ```ruby
1662
+ Parse.pluralized_aliases = false # programmatic
1663
+ # or
1664
+ # PARSE_PLURALIZED_ALIASES=false # environment
1665
+ ```
1666
+
1667
+ 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:
1668
+
1669
+ ```ruby
1670
+ class Status < Parse::Object
1671
+ pluralized_alias! :Statuses # defines ::Statuses => Status
1672
+ end
1673
+
1674
+ module Blog
1675
+ class Article < Parse::Object
1676
+ pluralized_alias! # defines Blog::Articles => Blog::Article
1677
+ end
1678
+ end
1679
+ ```
1680
+
1681
+ 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).
1682
+
1683
+ 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.
1684
+
1685
+ > **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`.
1686
+
1625
1687
  ### Defining Properties
1626
1688
  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
1689
 
@@ -4930,6 +4992,80 @@ double-firing. `:if`/`:unless` conditions on these callbacks are honored.
4930
4992
  > most for client-initiated saves, where the callback runs inside the webhook —
4931
4993
  > Ruby-SDK saves run it in-process after their own REST response instead.
4932
4994
 
4995
+ #### Acting as the calling user
4996
+
4997
+ Parse Server includes the caller's live session token (`user.sessionToken`) in
4998
+ every trigger webhook fired by a logged-in user (it is absent for a master-key
4999
+ request). A handler can opt in to acting on the server **as that user** —
5000
+ authorized by Parse Server with full ACL, CLP, and `protectedFields`
5001
+ enforcement — instead of reaching for the application master key. Three opt-in
5002
+ handles are available on the `payload`:
5003
+
5004
+ | Handle | Returns | `nil` when |
5005
+ |---|---|---|
5006
+ | `payload.session_token` | the caller's raw token (`String`) | master-key request (no user) |
5007
+ | `payload.user_agent(**opts)` | a non-master `Parse::Agent` in **client mode**, token bound | no token |
5008
+ | `payload.user_client` | a non-master `Parse::Client` with the token **bound** | no token |
5009
+
5010
+ ```ruby
5011
+ Parse::Webhooks.route :after_save, :Post do
5012
+ next true unless session_token? # master-key save → no caller token
5013
+
5014
+ # A client-mode Parse::Agent scoped to the caller (read tools only unless
5015
+ # you pass allow_mutations: true). ACL/CLP enforced; no master-key fallback.
5016
+ visible = user_agent.execute(:query_class, class_name: "Post", limit: 20)
5017
+
5018
+ # …or a raw user-scoped Parse::Client. The token is BOUND, so plain REST
5019
+ # calls authorize as the user with no per-call session_token: argument:
5020
+ mine = user_client.request(:get, "classes/Post").result
5021
+ true
5022
+ end
5023
+ ```
5024
+
5025
+ The token is captured before the payload's credential scrubbing runs, so it is
5026
+ still removed from `payload.user` / `payload.object` and never appears in
5027
+ `payload.as_json`, `payload.inspect`, or the request log. `user_client` binds it
5028
+ via the `Parse::Client.new(session_token:)` option, applied as the
5029
+ lowest-priority auth fallback — an explicit per-call `session_token:`, a
5030
+ `Parse.with_session` block, or an explicit `use_master_key: true` all still take
5031
+ precedence. This applies to **webhook-delivered** triggers (including the model
5032
+ `webhook :before_save` DSL, which is HTTP-delivered); a genuine in-process
5033
+ ActiveModel `before_save :method` callback has no incoming request and therefore
5034
+ no caller token.
5035
+
5036
+ The same user-scoped client is available on the **client side**. Two shapes:
5037
+
5038
+ ```ruby
5039
+ # 1. A client object you can pass around (carries the user's token + the
5040
+ # server connection, no master key):
5041
+ client = Parse::User.login(username, password).session_client
5042
+ Parse::Query.new("Post", client: client).results # runs as the user
5043
+ # (Parse.client.become(token) builds the same thing from any token.)
5044
+
5045
+ # 2. A block that runs ordinary model operations as the user, without
5046
+ # threading session_token: through each call:
5047
+ Parse::User.login(username, password).with_session do
5048
+ Post.query.count # counts only the user's readable Posts
5049
+ Post.create(title: "Hi") # created under the user's permissions
5050
+ end
5051
+ ```
5052
+
5053
+ `with_session` (on a `Parse::User`, a `Parse::Client`, or `Parse.with_session`)
5054
+ scopes by binding the token as the ambient session — it authorizes
5055
+ **REST-routed** operations (`find` / `get` / `count` / `save`) as the user. It
5056
+ does **not** scope mongo-direct queries (`results_direct`, `aggregate`, Atlas
5057
+ search): those resolve auth from the query's own `session_token:` / `acl_user:`
5058
+ and otherwise run in **master** mode, so scope them explicitly with a per-query
5059
+ `session_token:` or a scoped `Parse::Agent`. (To run a query as a user *without*
5060
+ a token — via the master key and SDK-side ACL simulation — use
5061
+ `Parse::Query#scope_to_user(user)`.) `Parse::Client#anonymous` builds the same
5062
+ non-master client with no token, for an explicitly unauthenticated request.
5063
+
5064
+ To explore a server as a specific user from this repo, `rake client:console`
5065
+ opens an IRB session whose default client is a non-master client bound to a user
5066
+ (session token, login, or anonymous), so every query in the session runs with
5067
+ that user's ACL/CLP — with `whoami` and `as_master { … }` helpers.
5068
+
4933
5069
  `before_save` and `before_delete` hooks have special functionality and multiple ways to halt operations:
4934
5070
 
4935
5071
  1. **Using `error!` method**: Calling `error!` will return an error response to Parse Server
data/Rakefile CHANGED
@@ -35,6 +35,54 @@ def mcp_credentials_or_abort!
35
35
  [server_url, app_id, rest_api_key, master_key]
36
36
  end
37
37
 
38
+ # Resolve the identity for `rake client:console`. Returns a session-token String,
39
+ # or nil to mean "anonymous" (no token, no master). Order: PARSE_SESSION_TOKEN,
40
+ # PARSE_CLIENT_ANONYMOUS=true, PARSE_LOGIN_USER (+PARSE_LOGIN_PASSWORD), else
41
+ # prompt for login / token / anon. The login path uses the configured default
42
+ # client (the master client set up just before this is called).
43
+ def client_console_token!
44
+ token = ENV["PARSE_SESSION_TOKEN"].to_s.strip
45
+ return token unless token.empty?
46
+ return nil if ENV["PARSE_CLIENT_ANONYMOUS"].to_s == "true"
47
+
48
+ user = ENV["PARSE_LOGIN_USER"].to_s.strip
49
+ if user.empty?
50
+ print "Identity? [login/token/anon] (login): "
51
+ case $stdin.gets.to_s.strip.downcase
52
+ when "anon", "anonymous" then return nil
53
+ when "token"
54
+ print "Session token (r:...): "
55
+ t = $stdin.gets.to_s.strip
56
+ return t.empty? ? nil : t
57
+ else
58
+ print "Username (blank for anonymous): "
59
+ user = $stdin.gets.to_s.strip
60
+ return nil if user.empty?
61
+ end
62
+ end
63
+
64
+ pwd = ENV["PARSE_LOGIN_PASSWORD"].to_s
65
+ if pwd.empty?
66
+ begin
67
+ require "io/console"
68
+ print "Password for #{user}: "
69
+ pwd = $stdin.noecho(&:gets).to_s
70
+ puts
71
+ rescue LoadError, IOError, SystemCallError
72
+ # io/console missing, or stdin is not a TTY (piped input raises
73
+ # Errno::ENOTTY, a SystemCallError — not an IOError). Fall back to a
74
+ # plain read; warn that it will echo.
75
+ warn "[client:console] WARNING: cannot disable echo; password will be visible."
76
+ print "Password for #{user}: "
77
+ pwd = $stdin.gets.to_s
78
+ end
79
+ end
80
+ u = Parse::User.login(user, pwd.chomp)
81
+ abort "[client:console] login failed for #{user.inspect}" if u.nil? || u.session_token.to_s.empty?
82
+ puts "Logged in as #{u.username} (#{u.id})."
83
+ u.session_token
84
+ end
85
+
38
86
  # Default test task runs all tests with Docker enabled.
39
87
  #
40
88
  # `*disruptive*` tests are EXCLUDED here: they stop/restart the shared
@@ -48,55 +96,92 @@ Rake::TestTask.new do |t|
48
96
  t.verbose = true
49
97
  end
50
98
 
99
+ # Shared runner for the file-per-process test tasks. Each test file runs in its
100
+ # own Ruby process (isolation against the shared Parse Server); output streams
101
+ # live, and — for trackability — a PASS/FAIL + duration line per file is printed
102
+ # to STDOUT *and* appended to a progress log you can `tail -f` from another
103
+ # shell while the run is in flight (works even when the run is backgrounded or
104
+ # piped, where STDOUT would otherwise buffer until completion).
105
+ #
106
+ # Env knobs:
107
+ # TEST_PATTERN=<substr> run only files whose path includes <substr>
108
+ # (e.g. TEST_PATTERN=webhook)
109
+ # CONTINUE_ON_FAILURE=false stop at the first failing file
110
+ # (default: run them all, then list every failure)
111
+ #
112
+ # Exits non-zero if any file failed.
113
+ def run_test_files!(label, files, log:)
114
+ $stdout.sync = true
115
+ require "fileutils"
116
+ if (pattern = ENV["TEST_PATTERN"].to_s).length.positive?
117
+ files = files.select { |f| f.include?(pattern) }
118
+ puts "TEST_PATTERN=#{pattern} -> #{files.length} matching file(s)"
119
+ end
120
+ continue = ENV.fetch("CONTINUE_ON_FAILURE", "true") != "false"
121
+ FileUtils.mkdir_p(File.dirname(log))
122
+ total = files.length
123
+ started = Time.now
124
+ results = []
125
+ File.write(log, "#{label}: #{total} files, started #{started}\n")
126
+ puts "\n>> #{label}: #{total} files (progress log: #{log})"
127
+
128
+ files.each_with_index do |file, i|
129
+ n = i + 1
130
+ puts "\n" + "=" * 80
131
+ puts "[#{n}/#{total}] #{file}"
132
+ puts "=" * 80
133
+ t0 = Time.now
134
+ ok = system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}")
135
+ dt = Time.now - t0
136
+ results << [file, ok, dt]
137
+ summary = format("[%d/%d] %-4s %7.1fs %s", n, total, ok ? "PASS" : "FAIL", dt, file)
138
+ puts summary
139
+ File.open(log, "a") { |f| f.puts summary }
140
+ if !ok && !continue
141
+ File.open(log, "a") { |f| f.puts "STOPPED at first failure (CONTINUE_ON_FAILURE=false)" }
142
+ break
143
+ end
144
+ end
145
+
146
+ elapsed = Time.now - started
147
+ passed = results.count { |_, ok, _| ok }
148
+ failed = results.reject { |_, ok, _| ok }
149
+ footer = format("%s: %d/%d passed in %.1fs (%.1f min)",
150
+ label, passed, results.length, elapsed, elapsed / 60.0)
151
+ puts "\n" + "=" * 80
152
+ puts footer
153
+ unless failed.empty?
154
+ puts "Failed (#{failed.length}):"
155
+ failed.each { |f, _, d| puts format(" FAIL %7.1fs %s", d, f) }
156
+ end
157
+ puts "=" * 80
158
+ File.open(log, "a") do |f|
159
+ f.puts footer
160
+ failed.each { |ff, _, d| f.puts format(" FAIL %7.1fs %s", d, ff) }
161
+ end
162
+ exit(1) unless failed.empty?
163
+ end
164
+
51
165
  # Integration tests require Docker
52
166
  namespace :test do
53
- desc "Run all integration tests (requires Docker)"
167
+ desc "Run all integration tests (requires Docker). " \
168
+ "Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
54
169
  task :integration do
55
170
  # Disruptive tests (server stop/restart) are run separately via
56
171
  # `test:integration:disruptive` so they never interleave with — and
57
172
  # flake — the rest of the integration suite against the shared server.
58
- integration_files = FileList["test/lib/**/*integration_test.rb"]
59
- .exclude("test/lib/**/*disruptive*")
60
-
61
- puts "Running #{integration_files.length} integration test files..."
62
- integration_files.each_with_index do |file, index|
63
- puts "Running integration test #{index + 1}/#{integration_files.length}: #{file}"
64
-
65
- # 10: docker integration test fails for cloud functions
66
- skip_till = 0
67
- if (index + 1) <= skip_till
68
- puts "Skipping test #{index + 1} as per configuration\n"
69
- next
70
- end
71
-
72
- puts "\n" + "="*80
73
- puts "Running: #{file}"
74
- puts "="*80
75
- system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || exit(1)
76
- end
77
- puts "\n✅ All integration tests completed successfully!"
173
+ files = FileList["test/lib/**/*integration_test.rb"]
174
+ .exclude("test/lib/**/*disruptive*")
175
+ run_test_files!("Integration tests", files, log: "tmp/integration-progress.log")
78
176
  end
79
177
 
80
- desc "Run unit tests only (no Docker required)"
178
+ desc "Run unit tests only (no Docker required). " \
179
+ "Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
81
180
  task :unit do
82
- unit_files = FileList["test/lib/**/*_test.rb"]
83
- .exclude("test/lib/**/*integration_test.rb")
84
- .exclude("test/lib/**/*disruptive*")
85
-
86
- puts "Running #{unit_files.length} unit test files (no Docker)..."
87
- unit_files.each_with_index do |file, index|
88
- puts "Running unit test #{index + 1}/#{unit_files.length}: #{file}"
89
-
90
- # 73 is problematic Testing Contains and Nin with Parse Objects with contains and nin
91
- skip_till = 0
92
- if (index + 1) <= skip_till
93
- puts "Skipping test #{index + 1} as per configuration"
94
- next
95
- end
96
-
97
- system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || exit(1)
98
- end
99
- puts "\n✅ All unit tests completed successfully!"
181
+ files = FileList["test/lib/**/*_test.rb"]
182
+ .exclude("test/lib/**/*integration_test.rb")
183
+ .exclude("test/lib/**/*disruptive*")
184
+ run_test_files!("Unit tests", files, log: "tmp/unit-progress.log")
100
185
  end
101
186
 
102
187
  namespace :integration do
@@ -602,6 +687,74 @@ namespace :mcp do
602
687
  end
603
688
  end
604
689
 
690
+ # rake client:console — IRB whose DEFAULT client is a non-master client bound to
691
+ # a user's session, so every model query runs as that user (ACL/CLP enforced).
692
+ # Identity: PARSE_SESSION_TOKEN, or PARSE_LOGIN_USER/PARSE_LOGIN_PASSWORD, else
693
+ # prompt. Connection env matches the other tasks; the master key is used only to
694
+ # log in. Helpers in the REPL: client, whoami, as_master { … }.
695
+ namespace :client do
696
+ desc "Interactive console authenticated as a Parse user (session token or login), not master"
697
+ task :console do
698
+ require "irb"
699
+ begin; require "dotenv/load"; rescue LoadError; end
700
+ $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
701
+ require "parse-stack"
702
+
703
+ server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
704
+ Parse.setup(server_url: server_url, application_id: app_id, api_key: rest_api_key, master_key: master_key)
705
+ master_client = Parse.client
706
+ token = client_console_token! # String, or nil for anonymous
707
+
708
+ # Models memoize their resolved client at the class level (`@client ||=`),
709
+ # so swapping clients[:default] alone is NOT enough — every swap must also
710
+ # drop those cached ivars or already-touched classes keep the old client.
711
+ reset_client_caches = lambda do
712
+ next unless defined?(Parse::Object)
713
+ [Parse::Object, *Parse::Object.descendants, Parse::Query].each do |k|
714
+ k.remove_instance_variable(:@client) if k.instance_variable_defined?(:@client)
715
+ end
716
+ end
717
+
718
+ # Make a non-master client (session-bound, or anonymous) the default so
719
+ # every model query runs as the user.
720
+ user_client = token ? master_client.become(token) : master_client.anonymous
721
+ Parse::Client.clients[:default] = user_client
722
+ reset_client_caches.call
723
+
724
+ Object.send(:define_method, :client) { user_client }
725
+ # Redacted: GET /users/me returns a Parse::User carrying the live
726
+ # sessionToken, and Parse::Object has no redacted #inspect, so returning the
727
+ # raw object would print the token in the REPL. Hand back a safe summary.
728
+ Object.send(:define_method, :whoami) do
729
+ next "anonymous (no session)" unless token
730
+ r = user_client.current_user(token)
731
+ next "whoami failed: #{r.error}" unless r.success?
732
+ u = r.result
733
+ { "username" => u["username"], "objectId" => u["objectId"], "session_token" => "[FILTERED]" }
734
+ rescue StandardError => e
735
+ "whoami failed: #{e.message}"
736
+ end
737
+ # Escalate to the master key for the block, then restore the user client.
738
+ # Both swaps must reset the class client caches (see reset_client_caches),
739
+ # otherwise a class touched inside the block keeps master afterward, or a
740
+ # previously-touched class never escalates. Already-instantiated Parse::Query
741
+ # objects held in REPL locals keep their own client and are not reset.
742
+ Object.send(:define_method, :as_master) do |&blk|
743
+ Parse::Client.clients[:default] = master_client
744
+ reset_client_caches.call
745
+ blk.call
746
+ ensure
747
+ Parse::Client.clients[:default] = user_client
748
+ reset_client_caches.call
749
+ end
750
+
751
+ mode = token ? "a USER" : "ANONYMOUS"
752
+ puts "parse-stack-next client console — as #{mode} (no master key). Helpers: client, whoami, as_master { }."
753
+ ARGV.clear
754
+ IRB.start
755
+ end
756
+ end
757
+
605
758
  desc "List undocumented methods"
606
759
  task "yard:stats" do
607
760
  exec "yard stats --list-undoc"
@@ -180,6 +180,39 @@ This duality is intentional. The high-level convenience method matches
180
180
  what mobile SDKs do; the raw client preserves the response so you can
181
181
  log or reroute.
182
182
 
183
+ #### A user-scoped client straight from login
184
+
185
+ `Parse::User#session_client` turns a logged-in user into a **non-master client
186
+ with that user's token bound**, so you don't thread `session_token:` through
187
+ every call. (`Parse.client.become(token)` builds the same thing from any token.)
188
+ `Parse::User#with_session` runs a block as the user:
189
+
190
+ ```ruby
191
+ client = Parse::User.login("ada", "p4ssw0rd!").session_client
192
+ Parse::Query.new("Post", client: client).results # runs as Ada
193
+
194
+ # Or a block — every REST-routed op inside is authorized as Ada:
195
+ Parse::User.login("ada", "p4ssw0rd!").with_session do
196
+ Post.query.count
197
+ Post.create(title: "Hello")
198
+ end
199
+ ```
200
+
201
+ `session_client` returns `nil` if the user has no session token (e.g. it was
202
+ fetched/saved under the master key rather than logged in). The bound token is
203
+ applied as the lowest-priority auth fallback, so an explicit per-call
204
+ `session_token:`, a `Parse.with_session` block, or `use_master_key: true` all
205
+ still take precedence.
206
+
207
+ > **Scope boundary.** `with_session` (and `Parse.with_session`) authorize
208
+ > **REST-routed** operations (`find` / `get` / `count` / `save`) as the user.
209
+ > Mongo-direct queries (`results_direct`, `aggregate`, Atlas search) do **not**
210
+ > pick up the ambient session — scope them explicitly with a per-query
211
+ > `session_token:` or a scoped `Parse::Agent`. A no-master client like this one
212
+ > has no mongo-direct path anyway. To run a query as a user *without* a token —
213
+ > via the master key and SDK-side ACL simulation — use
214
+ > `Parse::Query#scope_to_user(user)`.
215
+
183
216
  ### 2.3 Validate / refresh a session
184
217
 
185
218
  ```ruby