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 +4 -4
- data/.bundle/config +1 -0
- data/CHANGELOG.md +155 -0
- data/Gemfile.lock +1 -1
- data/README.md +136 -0
- data/Rakefile +193 -40
- data/docs/client_sdk_guide.md +33 -0
- data/docs/mcp_guide.md +60 -0
- data/lib/parse/client.rb +119 -7
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/user.rb +20 -0
- data/lib/parse/model/core/pluralized_aliases.rb +30 -0
- data/lib/parse/model/core/properties.rb +27 -0
- data/lib/parse/model/core/querying.rb +70 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +156 -1
- data/lib/parse/webhooks/payload.rb +147 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90a966c230e0a0e2e6fd814170726e3af713dae06c0341a00cd7581eecbe53ee
|
|
4
|
+
data.tar.gz: b171ed432c7ea7806ec07653b47122bd6102a16046c980d915fe45f98ffbca32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 510f3f2deb860cedc73f11455fb5d4789c327923abebad4e26b223ae9c385374ffa23bc9564561e7d4dddb31b0c1a015b7e7222dacd7c0b18bdb01199580d7a0
|
|
7
|
+
data.tar.gz: cd0fb4c706b3c33f12059c7431cf75abd792cbd5ba8b193dd43256aaa70c8fcdd3f86e10b3ad8ffb25c8e9ccc3f985a61bcaa8b49d24badf6f8358cec2b16f3d
|
data/.bundle/config
CHANGED
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
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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"
|
data/docs/client_sdk_guide.md
CHANGED
|
@@ -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
|