parse-stack-next 4.5.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- data/parse-stack.png +0 -0
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
# Client SDK Guide
|
|
2
|
+
|
|
3
|
+
How to use `parse-stack-next` as an **unprivileged Parse client** — the way a
|
|
4
|
+
mobile app, browser, untrusted worker, or any process you don't trust with
|
|
5
|
+
the master key would use it.
|
|
6
|
+
|
|
7
|
+
This guide is the complement to the rest of the documentation, which
|
|
8
|
+
generally assumes the process holds master-key credentials. Here we assume
|
|
9
|
+
the opposite: the SDK is configured **without** a master key, all requests
|
|
10
|
+
go over REST, and authorization is carried by the user's `sessionToken`.
|
|
11
|
+
Every claim below is locked in by the integration tests under
|
|
12
|
+
`test/lib/parse/client_*_integration_test.rb`.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why a separate guide?
|
|
17
|
+
|
|
18
|
+
The default Parse Stack docs lean on convenience surfaces (`Song.find`,
|
|
19
|
+
`Song.create!`, `Song.first`) that resolve credentials implicitly through
|
|
20
|
+
`Parse.client`. Those calls work transparently because the configured
|
|
21
|
+
client carries the master key — Parse Server treats the request as an
|
|
22
|
+
admin operation, ACL/CLP/`protectedFields` checks are bypassed, and you
|
|
23
|
+
get whatever you asked for.
|
|
24
|
+
|
|
25
|
+
A client-mode process is the opposite world:
|
|
26
|
+
|
|
27
|
+
* No master key in the process. Ever. (If it's there, the operator made
|
|
28
|
+
a mistake — the SDK should never paper over it.)
|
|
29
|
+
* Authorization is per-call: every save, fetch, query, file upload, and
|
|
30
|
+
cloud-function invocation has to carry the caller's `sessionToken`.
|
|
31
|
+
* Parse Server is the enforcement boundary. CLP rejects the call; ACL
|
|
32
|
+
filters rows; `protectedFields` strips columns. The SDK's job is to
|
|
33
|
+
thread the auth context through honestly and surface the server's
|
|
34
|
+
verdict — not to retry-with-master or invent a happy path.
|
|
35
|
+
* Several surfaces are simply unavailable: `/aggregate`, `/schemas`, full
|
|
36
|
+
`/sessions` enumeration, `/config` writes. They're master-key-only on
|
|
37
|
+
Parse Server, and the SDK fails closed when you call them without it.
|
|
38
|
+
|
|
39
|
+
If you've used Parse Stack with the master key and find that "the same
|
|
40
|
+
calls just stop working" when you remove it — that's not a regression.
|
|
41
|
+
That's Parse Server doing what it's documented to do, and this guide is
|
|
42
|
+
the field manual for working within it.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 1. Configuration
|
|
47
|
+
|
|
48
|
+
### 1.1 No-master-key client
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
require "parse/stack"
|
|
52
|
+
|
|
53
|
+
Parse.setup(
|
|
54
|
+
server_url: "https://parse.example.com/parse",
|
|
55
|
+
app_id: "MY_APP_ID",
|
|
56
|
+
api_key: "MY_REST_API_KEY",
|
|
57
|
+
master_key: nil, # explicit; do NOT set this from env in client builds
|
|
58
|
+
logging: false,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
Parse.client.master_key # => nil
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
That's the whole knob. Once `master_key` is `nil`, every call that
|
|
65
|
+
resolves through `Parse.client` (which is essentially all of them) goes
|
|
66
|
+
out as a regular REST request. The server has no admin escape hatch to
|
|
67
|
+
fall back on.
|
|
68
|
+
|
|
69
|
+
### 1.2 Building a one-off client
|
|
70
|
+
|
|
71
|
+
If you need a side client (e.g. a worker that handles uploads on behalf
|
|
72
|
+
of a logged-in user) and don't want to touch the global one:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
client = Parse::Client.new(
|
|
76
|
+
server_url: "https://parse.example.com/parse",
|
|
77
|
+
app_id: "MY_APP_ID",
|
|
78
|
+
api_key: "MY_REST_API_KEY",
|
|
79
|
+
master_key: nil,
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Most SDK surfaces operate on the global `Parse.client`; the one-off form
|
|
84
|
+
is mostly useful for tests and adapters.
|
|
85
|
+
|
|
86
|
+
### 1.3 Verifying you're really in client mode
|
|
87
|
+
|
|
88
|
+
The easiest mistake to make is "I thought I dropped the master key but
|
|
89
|
+
something is still threading it through." Pin it down explicitly:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
raise "client builds must not ship master key" if Parse.client.master_key.present?
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The test harness ships an `assert_client_mode!` helper that does exactly
|
|
96
|
+
this; production code should be just as paranoid.
|
|
97
|
+
|
|
98
|
+
#### 1.3.1 v5.0: `Parse::Query` master-key default flipped to `nil`
|
|
99
|
+
|
|
100
|
+
Before v5.0, `Parse::Query#initialize` set `@use_master_key = true`. That
|
|
101
|
+
silently broke `Parse.client_mode = true` and every `Parse.with_session`
|
|
102
|
+
block: the truthy default propagated into `_opts` on every find, so the
|
|
103
|
+
request layer saw an explicit `use_master_key: true` and skipped the
|
|
104
|
+
client-mode resolution path entirely. Effect: queries went out
|
|
105
|
+
master-key-stamped regardless of operator intent.
|
|
106
|
+
|
|
107
|
+
v5.0 changes the init value to `nil` (tri-state: "no caller preference"):
|
|
108
|
+
|
|
109
|
+
- Server-mode unchanged. With a master key configured and no client_mode,
|
|
110
|
+
the request layer still defaults to sending it when nothing else
|
|
111
|
+
expresses a preference.
|
|
112
|
+
- Client-mode honored. `Parse.client_mode = true` now actually suppresses
|
|
113
|
+
the master-key header for queries, the way the rest of the surface
|
|
114
|
+
already did.
|
|
115
|
+
- Ambient session honored. Inside `Parse.with_session(user) { … }`, a plain
|
|
116
|
+
`Song.all(...)` now picks up the ambient instead of being short-circuited
|
|
117
|
+
by the old `true` default.
|
|
118
|
+
- Explicit wins. `query.use_master_key = true` or
|
|
119
|
+
`Song.all(..., use_master_key: true)` still forces the header.
|
|
120
|
+
|
|
121
|
+
Mongo-direct gate: `Parse::Query#assert_mongo_direct_routable!` treats
|
|
122
|
+
a configured master key on the client as an ambient credential in
|
|
123
|
+
server mode. Direct-only constraints (Atlas Search-shaped operators,
|
|
124
|
+
etc.) route through the mongo-direct path as long as `Parse.client_mode`
|
|
125
|
+
is false and `use_master_key` was not explicitly set to `false` — server
|
|
126
|
+
apps don't have to thread `use_master_key: true` through every query
|
|
127
|
+
that hits a direct-only constraint. The gate raises
|
|
128
|
+
`Parse::Query::MongoDirectRequired` for client-mode processes or queries
|
|
129
|
+
that explicitly opt out of the master key without supplying a
|
|
130
|
+
`session_token` / `.scope_to_user(user)` / `.scope_to_role(role)`.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 2. Authentication
|
|
135
|
+
|
|
136
|
+
### 2.1 Sign up
|
|
137
|
+
|
|
138
|
+
A user is created by `POST /users` — no auth required. `Parse::User#save`
|
|
139
|
+
on a brand-new user does signup-on-save and the response carries the
|
|
140
|
+
fresh `sessionToken`:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
user = Parse::User.new(
|
|
144
|
+
username: "ada",
|
|
145
|
+
password: "p4ssw0rd!",
|
|
146
|
+
email: "ada@example.com",
|
|
147
|
+
)
|
|
148
|
+
user.save # => true
|
|
149
|
+
user.session_token # => "r:abcd…"
|
|
150
|
+
user.id # => "oP3Q…"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Equivalent explicit form:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
user.signup!
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`signup!` raises on failure (duplicate username, missing required field);
|
|
160
|
+
`save` returns `false` and populates `user.errors`.
|
|
161
|
+
|
|
162
|
+
### 2.2 Log in
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
me = Parse::User.login("ada", "p4ssw0rd!")
|
|
166
|
+
me.session_token # => "r:abcd…"
|
|
167
|
+
me.logged_in? # => true
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`Parse::User.login` **returns nil** on bad credentials — it does not raise.
|
|
171
|
+
If you need the underlying error info, drop down to the client:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
response = Parse.client.login("ada", "wrong")
|
|
175
|
+
response.success? # => false
|
|
176
|
+
response.error # => "Invalid username/password."
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
This duality is intentional. The high-level convenience method matches
|
|
180
|
+
what mobile SDKs do; the raw client preserves the response so you can
|
|
181
|
+
log or reroute.
|
|
182
|
+
|
|
183
|
+
### 2.3 Validate / refresh a session
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
response = Parse.client.current_user(token)
|
|
187
|
+
response.success? # => true
|
|
188
|
+
response.result["objectId"] # => "oP3Q…"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`current_user` calls `GET /users/me`. A revoked or bogus token raises
|
|
192
|
+
`Parse::Error::InvalidSessionTokenError` — catch it if you want a
|
|
193
|
+
graceful "please log in again" UX, otherwise let it bubble up.
|
|
194
|
+
|
|
195
|
+
### 2.4 Log out
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
Parse.client.logout(token)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Subsequent `current_user(token)` calls will raise. There's no separate
|
|
202
|
+
client-side "session cache" to clear — the token was just a string you
|
|
203
|
+
were holding.
|
|
204
|
+
|
|
205
|
+
### 2.5 Multi-factor auth
|
|
206
|
+
|
|
207
|
+
If you've loaded the optional `parse/two_factor_auth/user_extension`
|
|
208
|
+
module on the server side and configured the matching cloud-code hook,
|
|
209
|
+
`Parse::User#mfa_enabled?` and related methods become available. The
|
|
210
|
+
plain `login` path still works for users who haven't enrolled; for users
|
|
211
|
+
who have, the MFA challenge flow is the standard Parse Server one and
|
|
212
|
+
the SDK threads it through.
|
|
213
|
+
|
|
214
|
+
This guide doesn't reproduce the MFA setup — see the `two_factor_auth`
|
|
215
|
+
module source for the full surface.
|
|
216
|
+
|
|
217
|
+
### 2.6 Anonymous users and upgrading them in place
|
|
218
|
+
|
|
219
|
+
Some apps want to give a visitor a real session before they pick a
|
|
220
|
+
username — so their first writes (a draft post, a cart, a configured
|
|
221
|
+
preference) attach to a row that survives across reloads and tabs and
|
|
222
|
+
then later promotes to a named account without losing anything.
|
|
223
|
+
|
|
224
|
+
`Parse::User.anonymous_signup` creates a fully-formed `_User` row with
|
|
225
|
+
an `authData.anonymous` provider entry and returns it pre-logged-in.
|
|
226
|
+
A client-generated UUID is supplied for the provider payload via
|
|
227
|
+
`SecureRandom.uuid`; the SDK constructs the `authData` shape so the
|
|
228
|
+
caller doesn't have to:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
guest = Parse::User.anonymous_signup
|
|
232
|
+
guest.session_token # => "r:abcd…"
|
|
233
|
+
guest.anonymous? # => true
|
|
234
|
+
guest.username # server-assigned random username
|
|
235
|
+
|
|
236
|
+
draft = Post.new(body: "first thoughts", author: guest)
|
|
237
|
+
draft.save(session: guest.session_token)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The token is a real session token — every CRUD/query example in §3
|
|
241
|
+
works against `guest.session_token` the same way it would for a named
|
|
242
|
+
user. ACL stamping under `acl_policy :owner_else_*` picks up the
|
|
243
|
+
anonymous user's objectId, so the row remains writable by whoever
|
|
244
|
+
holds the upgraded credentials later.
|
|
245
|
+
|
|
246
|
+
When the visitor signs up for real, **don't create a second `_User`
|
|
247
|
+
row** — upgrade the anonymous one in place:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
Parse.with_session(guest.session_token) do
|
|
251
|
+
guest.upgrade_anonymous!(
|
|
252
|
+
username: "ada",
|
|
253
|
+
password: "p4ssw0rd!",
|
|
254
|
+
email: "ada@example.com",
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
guest.anonymous? # => false
|
|
259
|
+
guest.username # => "ada"
|
|
260
|
+
guest.session_token # rotated by the server, applied automatically
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`upgrade_anonymous!` issues a single `PUT /users/:id` that sets the
|
|
264
|
+
new credentials AND explicitly unlinks the anonymous provider in the
|
|
265
|
+
same request (`authData: { anonymous: nil }`). The unlink is **not
|
|
266
|
+
optional** — leaving `authData.anonymous` attached after a username is
|
|
267
|
+
assigned would let anyone who learned the original anonymous UUID
|
|
268
|
+
silently log in as the freshly-named account. This is a documented
|
|
269
|
+
Parse foot-gun and the SDK closes it in one round trip.
|
|
270
|
+
|
|
271
|
+
Guards on `upgrade_anonymous!`:
|
|
272
|
+
|
|
273
|
+
* Requires `Parse.with_session(self.session_token)` (or a directly-set
|
|
274
|
+
`@session_token` on the instance) — the call writes via the user's
|
|
275
|
+
own session, not the master key.
|
|
276
|
+
* Refuses to run on a non-anonymous user, on a detached
|
|
277
|
+
`Parse::User.new` with no objectId, and on an instance with no
|
|
278
|
+
session token. All three raise `Parse::Error::AuthenticationError`
|
|
279
|
+
rather than performing an unauthorized PUT.
|
|
280
|
+
* On success, clears `password` from memory, applies the server-rotated
|
|
281
|
+
session token (when the server returns one), and runs
|
|
282
|
+
`changes_applied!` so a subsequent `save` doesn't re-transmit
|
|
283
|
+
credentials.
|
|
284
|
+
|
|
285
|
+
The Parse Server error codes for `username_taken` / `email_taken` /
|
|
286
|
+
`email_invalid` / missing-field surface as the existing
|
|
287
|
+
`Parse::Error::*` exception family — your existing signup error
|
|
288
|
+
handling works unchanged.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## 3. CRUD with a session token
|
|
293
|
+
|
|
294
|
+
The cardinal rule: **every save, fetch, query, and destroy needs to know
|
|
295
|
+
which session it's running as.** With no master key, the SDK has no
|
|
296
|
+
implicit "do whatever" path; you have to be explicit about who you are.
|
|
297
|
+
|
|
298
|
+
### 3.1 Save
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
post = Post.new(title: "hello", author: me)
|
|
302
|
+
post.save(session: me.session_token)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Or, using the lower-level API:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
Parse.client.create_object(
|
|
309
|
+
"Post", { "title" => "hello" },
|
|
310
|
+
session_token: me.session_token, use_master_key: false,
|
|
311
|
+
)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
`use_master_key: false` is the safety belt — it makes the call fail
|
|
315
|
+
loudly if some upstream code accidentally re-introduced a master key.
|
|
316
|
+
Get in the habit of writing it on every client-mode call.
|
|
317
|
+
|
|
318
|
+
> **Gotcha — kwarg absorption.** The SDK's `request` method uses a
|
|
319
|
+
> `**opts` splat, which silently absorbs a keyword named `opts:` into
|
|
320
|
+
> `{opts: {...}}` and DROPS your session token. Always pass auth as
|
|
321
|
+
> direct keywords (`session_token: …, use_master_key: false`), not as
|
|
322
|
+
> `opts: { … }`.
|
|
323
|
+
|
|
324
|
+
### 3.2 Update
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
post.title = "v2"
|
|
328
|
+
post.save(session: me.session_token)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Or:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
Parse.client.update_object(
|
|
335
|
+
"Post", post.id, { "title" => "v2" },
|
|
336
|
+
session_token: me.session_token, use_master_key: false,
|
|
337
|
+
)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### 3.3 Destroy
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
post.destroy(session: me.session_token)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
If the ACL doesn't grant write to the caller, the destroy returns false
|
|
347
|
+
(or raises `Parse::RecordNotSaved` depending on the code path) — the
|
|
348
|
+
row is left intact. Parse Server reports this as "Object not found"
|
|
349
|
+
which is its uniform shape for "you can't see it OR you can't touch it."
|
|
350
|
+
|
|
351
|
+
### 3.4 Fetch and query
|
|
352
|
+
|
|
353
|
+
The class-level convenience methods (`Post.find`, `Post.all`) **do not**
|
|
354
|
+
take a `session:` argument because they predate client mode. Use
|
|
355
|
+
`Parse::Query` and stamp the token on the query object:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
q = Post.query
|
|
359
|
+
q.session_token = me.session_token
|
|
360
|
+
posts = q.where(:likes.gte => 10).order(:likes.desc).limit(20).results
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
For one-off `find_by_id` against a class:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
Parse.client.fetch_object(
|
|
367
|
+
"Post", id,
|
|
368
|
+
session_token: me.session_token, use_master_key: false,
|
|
369
|
+
)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
`count` works the same way:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
q.where(:likes.gt => 0).count
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### 3.5 Pointer includes
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
q = Comment.query
|
|
382
|
+
q.session_token = me.session_token
|
|
383
|
+
comment = q.where(text: "nice").include(:post, :author).first
|
|
384
|
+
comment.post.title # populated via REST `?include=post`
|
|
385
|
+
comment.author.id # populated via `?include=author`
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
The server applies ACL to the included rows independently. If the
|
|
389
|
+
caller can read the comment but not the included `post`, `comment.post`
|
|
390
|
+
comes back as a bare pointer (just `objectId` + `className`) rather
|
|
391
|
+
than a hydrated object.
|
|
392
|
+
|
|
393
|
+
### 3.6 The snake_case ↔ camelCase trap
|
|
394
|
+
|
|
395
|
+
Ruby properties declared as `property :public_field, :string` are sent
|
|
396
|
+
on the wire as `publicField`. If you build a CLP schema, `protectedFields`
|
|
397
|
+
list, or raw query body, you **must** use the camelCase form:
|
|
398
|
+
|
|
399
|
+
```ruby
|
|
400
|
+
# WRONG — queries a column that doesn't exist server-side
|
|
401
|
+
Parse::Query.new("ClientClpProbe").where(public_field: "x") # SDK rewrites OK
|
|
402
|
+
# but:
|
|
403
|
+
{ "publicField" => "x" } # is what hits the wire — make sure the schema matches
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The Parse Stack query DSL handles the rewrite for you. Raw `find_objects`
|
|
407
|
+
/ `create_object` calls do not — pass camelCase keys when you're talking
|
|
408
|
+
to the low-level API.
|
|
409
|
+
|
|
410
|
+
### 3.7 Model callbacks run locally — NOT as Parse Cloud webhooks
|
|
411
|
+
|
|
412
|
+
This is the most-missed thing on the SDK→server transition. ActiveModel
|
|
413
|
+
callbacks declared on your `Parse::Object` subclasses (`before_save`,
|
|
414
|
+
`after_save`, `before_create`, `after_destroy`, attribute normalizers,
|
|
415
|
+
validations, etc.) execute **in the Ruby process** before the write hits
|
|
416
|
+
Parse Server. They are **not** registered as Parse Cloud Code triggers
|
|
417
|
+
(`Parse.Cloud.beforeSave('Contact', ...)`).
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
class Contact < Parse::Object
|
|
421
|
+
property :email, :string
|
|
422
|
+
|
|
423
|
+
before_save do
|
|
424
|
+
self.email = email.downcase if email.present?
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Concretely:
|
|
430
|
+
|
|
431
|
+
- `Contact.new(email: "Foo@BAR.com").save` from your Ruby app — the
|
|
432
|
+
`before_save` fires, `email` is lowercased, and `Foo@bar.com` lands on
|
|
433
|
+
the server as `"foo@bar.com"`. Good.
|
|
434
|
+
- A record `Contact` created by the iOS SDK, the JS SDK, a webhook, the
|
|
435
|
+
REST API directly, or the Parse Dashboard does **not** see your Ruby
|
|
436
|
+
callback. The server stores whatever it was given, mixed case and all.
|
|
437
|
+
- A separate Ruby process that imports the Parse Server schema but does
|
|
438
|
+
**not** define a `Contact` Ruby model also bypasses the callback.
|
|
439
|
+
- If you `update_object("Contact", id, { email: "Foo@BAR.com" })`
|
|
440
|
+
directly via the raw client (skipping the model), there is no Ruby
|
|
441
|
+
instance to run the callback on. The raw write goes through unchanged.
|
|
442
|
+
|
|
443
|
+
If you need invariants enforced on **every** write regardless of which
|
|
444
|
+
client sent it, that's Parse Cloud Code on the server (a
|
|
445
|
+
`Parse.Cloud.beforeSave('Contact', ...)` trigger in your cloud code
|
|
446
|
+
bundle) — not a Ruby model callback. Use Ruby callbacks for app-side
|
|
447
|
+
ergonomics (defaults, derived fields, post-save notifications **from
|
|
448
|
+
this app**), and use server-side Cloud Code triggers for cross-client
|
|
449
|
+
data integrity.
|
|
450
|
+
|
|
451
|
+
The same caveat applies to `after_save` — and this one bites harder,
|
|
452
|
+
because `after_save` is the natural home for "send the welcome email",
|
|
453
|
+
"enqueue the embedding job", "post to the activity feed", "invalidate
|
|
454
|
+
the cache". All of those only fire when the save originates from a Ruby
|
|
455
|
+
process holding a `Contact` model instance and calling `.save` on it.
|
|
456
|
+
A `Contact` created by:
|
|
457
|
+
|
|
458
|
+
- the iOS or JS SDK
|
|
459
|
+
- a separate Ruby service that doesn't define the `Contact` model
|
|
460
|
+
- the Parse Dashboard
|
|
461
|
+
- a direct REST call (`POST /parse/classes/Contact`)
|
|
462
|
+
- a Cloud Code `Parse.Cloud.run(...)` that constructs the row via the
|
|
463
|
+
JS Parse SDK
|
|
464
|
+
|
|
465
|
+
...will not trigger your Ruby `after_save`. The row appears in the
|
|
466
|
+
database and your "every Contact gets a welcome email" promise quietly
|
|
467
|
+
breaks. If a side effect must fire on **every** save regardless of
|
|
468
|
+
client, put it in a Parse Cloud Code `afterSave` trigger (server-side
|
|
469
|
+
JS) — or in an external worker that subscribes to a LiveQuery on the
|
|
470
|
+
class. Ruby `after_save` is for side effects scoped to *this* app's
|
|
471
|
+
saves only.
|
|
472
|
+
|
|
473
|
+
The same caveat applies to ACL defaults, derived fields, soft-delete
|
|
474
|
+
flags, audit columns — anything you wire into a Ruby callback expecting
|
|
475
|
+
it to "always run" only runs when the write originates from this Ruby
|
|
476
|
+
process through this model class.
|
|
477
|
+
|
|
478
|
+
#### Same-stack deployments: don't double-fire non-idempotent hooks
|
|
479
|
+
|
|
480
|
+
A pure no-master-key client (what this guide covers) doesn't host Parse
|
|
481
|
+
Cloud Code webhooks, so the only place a callback can run is in your
|
|
482
|
+
Ruby model. No double-fire risk on this side of the wire.
|
|
483
|
+
|
|
484
|
+
That changes the moment the same Ruby process is **also** the master-key
|
|
485
|
+
server hosting the `Parse::Webhooks` Rack handler. In that dual-role
|
|
486
|
+
deployment, a single `contact.save` from your app can produce two
|
|
487
|
+
hook-firing opportunities — the local `after_save` in the calling
|
|
488
|
+
thread, *and* the Parse Cloud `afterSave` webhook trigger dispatched
|
|
489
|
+
back into the same process. Non-idempotent side effects (welcome
|
|
490
|
+
emails, billing increments, outbound API calls) will double up.
|
|
491
|
+
|
|
492
|
+
The mitigation lives on the **server-side / webhook** docs: the
|
|
493
|
+
master-key request origin is what lets a webhook handler short-circuit
|
|
494
|
+
when it sees a same-stack save. It is **not** a feature of the client
|
|
495
|
+
package and there is nothing to configure here. The principle to carry
|
|
496
|
+
across is just: pick one site per non-idempotent side effect (Ruby
|
|
497
|
+
model callback **or** Cloud Code webhook, never both), and if you're
|
|
498
|
+
about to run a Ruby `after_save` AND a `Parse::Webhooks.route(:after_save,
|
|
499
|
+
...)` handler that do the same work, that's the bug. See the webhooks
|
|
500
|
+
section of the main README and `lib/parse/webhooks.rb` for the
|
|
501
|
+
server-side guidance.
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## 4. ACL — the row-level boundary
|
|
506
|
+
|
|
507
|
+
Parse Server enforces ACL on every read and write against a non-master
|
|
508
|
+
caller. The SDK's job is to (a) thread the session token in so the server
|
|
509
|
+
has someone to check against, and (b) compose ACLs correctly on the
|
|
510
|
+
wire so the right people get the right access.
|
|
511
|
+
|
|
512
|
+
### 4.1 ACL policies on a class
|
|
513
|
+
|
|
514
|
+
```ruby
|
|
515
|
+
class Post < Parse::Object
|
|
516
|
+
parse_class "Post"
|
|
517
|
+
acl_policy :public # everyone can read/write by default
|
|
518
|
+
property :title, :string
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
class Note < Parse::Object
|
|
522
|
+
parse_class "Note"
|
|
523
|
+
acl_policy :owner_else_private # default — see below
|
|
524
|
+
property :body, :string
|
|
525
|
+
belongs_to :author, as: :user
|
|
526
|
+
end
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
| Policy | What gets stamped on save | When to use |
|
|
530
|
+
|--------------------------|--------------------------------------------------------|-----------------------------------|
|
|
531
|
+
| `:public` | `{"*": {"read": true, "write": true}}` | Public/anon-readable feeds |
|
|
532
|
+
| `:public_read` | `{"*": {"read": true}}` | Read-only catalogs, lookup tables |
|
|
533
|
+
| `:private` | `{}` (master-key-only) | System rows, audit logs |
|
|
534
|
+
| `:owner_else_private` | Owner ACL if `:author` resolves, else `{}` (master) | **Default** — safe by default |
|
|
535
|
+
| `:owner_else_public` | Owner ACL if `:author` resolves, else public | Public content authored by user |
|
|
536
|
+
| `:owner_but_public_read` | Owner R/W + `{"*": {"read": true}}` (public-read fallback when no owner) | Public posts authored by one user |
|
|
537
|
+
|
|
538
|
+
`:public_read` is read-anywhere, master-key-write — no client can mutate
|
|
539
|
+
the row through ACL. `:owner_but_public_read` is the "public posts with
|
|
540
|
+
one author" case: the resolved owner gets R/W while the rest of the world
|
|
541
|
+
gets read-only access; when no owner resolves it degrades to
|
|
542
|
+
`:public_read` semantics rather than master-key-only.
|
|
543
|
+
|
|
544
|
+
`:owner_else_private` is the SDK's default for a reason: if your model
|
|
545
|
+
forgets to declare an owner field, your rows are stamped master-only and
|
|
546
|
+
become invisible to clients. That's exactly what you want — a noisy
|
|
547
|
+
failure mode beats a silent permission leak.
|
|
548
|
+
|
|
549
|
+
### 4.2 Building an ACL on a record
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
post = Post.new(title: "draft")
|
|
553
|
+
post.acl.everyone(false, false) # turn off public
|
|
554
|
+
post.acl.apply(me.id, true, true) # owner: read + write
|
|
555
|
+
post.acl.apply_role("Editors", true, true) # role-grant read+write
|
|
556
|
+
post.save(session: me.session_token)
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Wire shape after `everyone(false, false) + apply(me.id, true, true)`:
|
|
560
|
+
|
|
561
|
+
```json
|
|
562
|
+
{ "<me.id>": { "read": true, "write": true } }
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The `*` entry is suppressed entirely (or persisted as `nil`, which Parse
|
|
566
|
+
Server treats as absent). There's no `{"*": {"read": false}}` on the
|
|
567
|
+
wire — that'd be redundant.
|
|
568
|
+
|
|
569
|
+
### 4.3 What clients see
|
|
570
|
+
|
|
571
|
+
* `acl.everyone(true, false)` → public-read, public-write-denied. Other
|
|
572
|
+
authenticated users and anonymous clients can fetch the row, but their
|
|
573
|
+
saves on the row are rejected.
|
|
574
|
+
* `acl.everyone(false, false) + acl.apply(me.id, true, true)` → strictly
|
|
575
|
+
owner-only. Other users get `nil` on fetch (Parse Server filters by
|
|
576
|
+
ACL on the query result; the row simply isn't in their result set).
|
|
577
|
+
* `:owner_else_private` with no resolved owner → empty ACL `{}`. Master
|
|
578
|
+
key only. Even the user who created the row can't see it from a
|
|
579
|
+
client session unless you also stamp an ACL.
|
|
580
|
+
|
|
581
|
+
### 4.4 The `_User` row
|
|
582
|
+
|
|
583
|
+
A user's own `_User` row is ACL'd to themselves at signup. They can
|
|
584
|
+
update their own email/password from a client session:
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
Parse.client.update_object(
|
|
588
|
+
"_User", me.id, { "email" => "new@example.com" },
|
|
589
|
+
session_token: me.session_token, use_master_key: false,
|
|
590
|
+
)
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
But **cannot** modify another user's `_User` row — Parse Server returns
|
|
594
|
+
"Insufficient auth." on the cross-user write attempt. This is enforced
|
|
595
|
+
server-side; the SDK just relays the rejection.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## 5. Roles — and a direction gotcha
|
|
600
|
+
|
|
601
|
+
Role grants apply at the row level the same way per-user grants do —
|
|
602
|
+
`acl.apply_role("Admin", true, true)` puts the Admin role on the row's
|
|
603
|
+
ACL and any user in Admin (or any role that inherits Admin) gets access.
|
|
604
|
+
|
|
605
|
+
### 5.1 Membership
|
|
606
|
+
|
|
607
|
+
```ruby
|
|
608
|
+
admin_role = Parse::Role.find_or_create("Admin")
|
|
609
|
+
admin_role.add_users(alice, bob).save
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
This must run under the master key. Parse Server defaults `_Role` CLP
|
|
613
|
+
to master-only writes — a non-master client cannot rename a role, add
|
|
614
|
+
users to it, or create one. Calling `update_object("_Role", …)` from
|
|
615
|
+
client mode returns an auth error; the SDK does not silently strip the
|
|
616
|
+
write.
|
|
617
|
+
|
|
618
|
+
### 5.2 Hierarchy — read this carefully
|
|
619
|
+
|
|
620
|
+
This is the single most counter-intuitive piece of Parse Server role
|
|
621
|
+
semantics. The shorthand "role hierarchy" can mean two opposite things
|
|
622
|
+
and the SDK exposes both, with sharply different names.
|
|
623
|
+
|
|
624
|
+
Per Parse Server's `getAllRolesForUser` expansion: a role's `roles`
|
|
625
|
+
relation contains *child roles whose users inherit access through this
|
|
626
|
+
role*. Put another way: if you want **SuperAdmin to inherit Admin's
|
|
627
|
+
capabilities**, you put **SuperAdmin into Admin's `roles` relation** —
|
|
628
|
+
not the reverse.
|
|
629
|
+
|
|
630
|
+
The SDK exposes a direction-explicit method to avoid mistakes:
|
|
631
|
+
|
|
632
|
+
```ruby
|
|
633
|
+
super_role = Parse::Role.find_or_create("SuperAdmin")
|
|
634
|
+
super_role.add_users(super_user).save
|
|
635
|
+
|
|
636
|
+
# "SuperAdmin should inherit everything Admin can do."
|
|
637
|
+
super_role.inherits_capabilities_from!(admin_role)
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Under the hood this adds SuperAdmin to Admin's `roles` relation. Now any
|
|
641
|
+
row ACL'd to `role:Admin` is readable by SuperAdmin members too, because
|
|
642
|
+
the server's role-graph expansion traverses Admin → SuperAdmin when
|
|
643
|
+
resolving the caller's effective roles.
|
|
644
|
+
|
|
645
|
+
The older `add_child_role` method goes the **other direction** and is
|
|
646
|
+
preserved for backwards compatibility. If you find yourself reaching for
|
|
647
|
+
it: stop, and use `inherits_capabilities_from!` instead. Getting the
|
|
648
|
+
direction wrong is a privilege-escalation bug, not just a confusion.
|
|
649
|
+
|
|
650
|
+
---
|
|
651
|
+
|
|
652
|
+
## 6. CLP — the class-level boundary
|
|
653
|
+
|
|
654
|
+
Class-Level Permissions live one layer above ACL. They gate **what
|
|
655
|
+
operations are even allowed on the class** before ACL is consulted on
|
|
656
|
+
individual rows.
|
|
657
|
+
|
|
658
|
+
CLP is master-key-only to configure. From client mode you observe its
|
|
659
|
+
effects; you can't change it.
|
|
660
|
+
|
|
661
|
+
### 6.1 The common shape
|
|
662
|
+
|
|
663
|
+
```ruby
|
|
664
|
+
schema = {
|
|
665
|
+
"className" => "Note",
|
|
666
|
+
"fields" => {
|
|
667
|
+
"body" => { "type" => "String" },
|
|
668
|
+
"secretField" => { "type" => "String" },
|
|
669
|
+
},
|
|
670
|
+
"classLevelPermissions" => {
|
|
671
|
+
"find" => { "requiresAuthentication" => true },
|
|
672
|
+
"get" => { "requiresAuthentication" => true },
|
|
673
|
+
"count" => { "requiresAuthentication" => true },
|
|
674
|
+
"create" => { "requiresAuthentication" => true },
|
|
675
|
+
"update" => { "requiresAuthentication" => true },
|
|
676
|
+
"delete" => { "requiresAuthentication" => true },
|
|
677
|
+
"addField" => {}, # master-key-only
|
|
678
|
+
"protectedFields" => {
|
|
679
|
+
"*" => ["secretField"], # strip for everyone but master
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
Parse.client.update_schema("Note", schema)
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
With `requiresAuthentication: true` on `find/get/create`, an anonymous
|
|
688
|
+
(no-token) client call gets rejected before ACL is even consulted —
|
|
689
|
+
the response carries `code: 101` and an error like
|
|
690
|
+
`"Permission denied, user needs to be authenticated."`. CLP errors
|
|
691
|
+
**do not raise** in the SDK; check `response.success?` and read
|
|
692
|
+
`response.error`.
|
|
693
|
+
|
|
694
|
+
### 6.2 `protectedFields` — write-but-not-read
|
|
695
|
+
|
|
696
|
+
```ruby
|
|
697
|
+
"protectedFields" => { "*" => ["secretField"] }
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
This is the canonical "client sets it but cannot read it back" pattern.
|
|
701
|
+
A client-mode caller can write `secretField` on create/update (Parse
|
|
702
|
+
Server accepts the field in the POST body), but the GET/find readback
|
|
703
|
+
omits the column. Master-key fetch still sees the value, confirming it
|
|
704
|
+
was persisted — not silently dropped.
|
|
705
|
+
|
|
706
|
+
Both `Parse.client.fetch_object` and `Parse::Query#results` strip the
|
|
707
|
+
protected field; the SDK doesn't try to re-synthesize it from any
|
|
708
|
+
cache. If you see it in your client-side result, your CLP is wrong.
|
|
709
|
+
|
|
710
|
+
### 6.3 ACL still applies under CLP
|
|
711
|
+
|
|
712
|
+
CLP says "is this class operation allowed at all?". ACL says "given the
|
|
713
|
+
operation is allowed, which rows does this caller see / touch?". An
|
|
714
|
+
authed user who passed the CLP gate still gets their result set filtered
|
|
715
|
+
by ACL — if Alice writes a row with `acl.apply(alice.id, true, true)`
|
|
716
|
+
only, Bob's query for it (under his own session) returns nothing.
|
|
717
|
+
|
|
718
|
+
---
|
|
719
|
+
|
|
720
|
+
## 7. Files
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
contents = File.read("note.txt")
|
|
724
|
+
response = Parse.client.create_file(
|
|
725
|
+
"note.txt", contents, "text/plain",
|
|
726
|
+
session_token: me.session_token, use_master_key: false,
|
|
727
|
+
)
|
|
728
|
+
file_name = response.result["name"] # server-assigned, deduplicated
|
|
729
|
+
file_url = response.result["url"]
|
|
730
|
+
|
|
731
|
+
# Attach to a row.
|
|
732
|
+
file = Parse::File.new(file_name, nil, "text/plain")
|
|
733
|
+
file.url = file_url
|
|
734
|
+
post = Post.new(title: "with-file", attachment: file)
|
|
735
|
+
post.save(session: me.session_token)
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
Parse Server's `fileUpload` configuration controls who's allowed to
|
|
739
|
+
upload:
|
|
740
|
+
|
|
741
|
+
* `enableForPublic: true` — anonymous clients can upload.
|
|
742
|
+
* `enableForAnonymousUser: true` — clients with an anonymous-user
|
|
743
|
+
Parse session can upload.
|
|
744
|
+
* `enableForAuthenticatedUser: true` — clients with a real session can
|
|
745
|
+
upload.
|
|
746
|
+
|
|
747
|
+
The SDK does not pre-flight this — if uploads are disabled, the server
|
|
748
|
+
returns a `File upload by …` error and the SDK surfaces it. If you want
|
|
749
|
+
authenticated uploads only, set `enableForPublic: false` and
|
|
750
|
+
`enableForAnonymousUser: false` and require a session token on every
|
|
751
|
+
upload call.
|
|
752
|
+
|
|
753
|
+
`Parse::File#save` (the convenience surface) runs through `Parse.client`
|
|
754
|
+
without an explicit session, so it inherits whatever session the
|
|
755
|
+
default client is configured with — which for client mode means
|
|
756
|
+
"anonymous unless your server allows it." Prefer
|
|
757
|
+
`Parse.client.create_file(…, session_token: …)` in client builds.
|
|
758
|
+
|
|
759
|
+
---
|
|
760
|
+
|
|
761
|
+
## 8. Cloud Code
|
|
762
|
+
|
|
763
|
+
```ruby
|
|
764
|
+
response = Parse.call_function(
|
|
765
|
+
"myFunction", { argument: "value" },
|
|
766
|
+
session_token: me.session_token, use_master_key: false,
|
|
767
|
+
)
|
|
768
|
+
response.result # whatever the cloud function returned
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
Cloud functions run server-side with whatever auth context you give
|
|
772
|
+
them. `Parse.User.current` inside the cloud function resolves to the
|
|
773
|
+
session token's user — the same user who called the function from the
|
|
774
|
+
client. Master-key behavior inside cloud functions is at the cloud
|
|
775
|
+
function's discretion (it can call `Parse.useMasterKey()` server-side).
|
|
776
|
+
From the SDK's perspective: pass the session token, get back the
|
|
777
|
+
function's result.
|
|
778
|
+
|
|
779
|
+
`beforeSave` / `afterSave` hooks fire on client-mode saves the same way
|
|
780
|
+
they fire on master-key saves. If you have a hook that promotes
|
|
781
|
+
permissions or validates a write, it runs on the client request — the
|
|
782
|
+
SDK doesn't bypass cloud-code hooks just because the caller is
|
|
783
|
+
unprivileged.
|
|
784
|
+
|
|
785
|
+
### 8.1 Push notifications — server-side only via a cloud function
|
|
786
|
+
|
|
787
|
+
Parse Server's `POST /parse/push` endpoint is **master-key-only**.
|
|
788
|
+
There is no session-token authorization model on this surface; the
|
|
789
|
+
server unconditionally rejects pushes that aren't admin-stamped. The
|
|
790
|
+
SDK fails fast on this in client mode rather than letting the call
|
|
791
|
+
leave the process anonymous:
|
|
792
|
+
|
|
793
|
+
```ruby
|
|
794
|
+
Parse.client.push({ where: { deviceType: "ios" }, data: { alert: "hi" } })
|
|
795
|
+
# => raises Parse::Error::AuthenticationError("requires master key")
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
The guard fires at the SDK boundary, **before any network request**.
|
|
799
|
+
Passing `use_master_key: true` from a client-mode caller still raises
|
|
800
|
+
— the guard checks the client's actual `master_key`, not the per-call
|
|
801
|
+
opt. This is intentional: a no-master client cannot send a push under
|
|
802
|
+
any flag combination, and the failure is loud enough that callers
|
|
803
|
+
notice in dev rather than shipping a silent no-op to production.
|
|
804
|
+
|
|
805
|
+
The correct pattern is to put push behind a **cloud function** that
|
|
806
|
+
the client invokes with its session token. The function decides (a)
|
|
807
|
+
whether the caller is allowed to trigger this push and (b) which
|
|
808
|
+
audience the push targets — both decisions happen server-side under
|
|
809
|
+
admin context:
|
|
810
|
+
|
|
811
|
+
```js
|
|
812
|
+
// In cloud/main.js on the server
|
|
813
|
+
Parse.Cloud.define("notifyFollowers", async (req) => {
|
|
814
|
+
const user = req.user;
|
|
815
|
+
if (!user) throw "Authentication required";
|
|
816
|
+
|
|
817
|
+
// Server-side authz: only paid accounts can fan-out push
|
|
818
|
+
if (!user.get("subscriptionActive")) {
|
|
819
|
+
throw "Subscription required to send notifications";
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
await Parse.Push.send(
|
|
823
|
+
{
|
|
824
|
+
where: new Parse.Query("_Installation").equalTo("followsUser", user),
|
|
825
|
+
data: { alert: req.params.message, badge: "Increment" },
|
|
826
|
+
},
|
|
827
|
+
{ useMasterKey: true } // server-side, never trusted from the client
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
return { sent: true };
|
|
831
|
+
});
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
From the client, the call is an ordinary cloud-function invocation
|
|
835
|
+
threaded with the session token — no master key in the client
|
|
836
|
+
process, no `/push` REST call, no chance of audience-targeting being
|
|
837
|
+
controlled by an attacker who tampers with the wire payload:
|
|
838
|
+
|
|
839
|
+
```ruby
|
|
840
|
+
Parse.with_session(me.session_token) do
|
|
841
|
+
response = Parse.call_function(
|
|
842
|
+
"notifyFollowers",
|
|
843
|
+
{ message: "New post" },
|
|
844
|
+
use_master_key: false,
|
|
845
|
+
)
|
|
846
|
+
response.success? # => true / false
|
|
847
|
+
end
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
Two reasons this is the right shape, not just a workaround:
|
|
851
|
+
|
|
852
|
+
1. **Audience targeting belongs on the server.** A client that
|
|
853
|
+
constructs a `where:` query and posts it to `/push` has full
|
|
854
|
+
control over who receives the notification. With a cloud function
|
|
855
|
+
in front, the server owns the `Parse.Query("_Installation")`
|
|
856
|
+
construction; the client only supplies the message body.
|
|
857
|
+
2. **The same cloud function is a natural choke point for rate
|
|
858
|
+
limiting, abuse signals, and audit trails.** None of those belong
|
|
859
|
+
in a client process, and `/push` doesn't expose hooks for them.
|
|
860
|
+
|
|
861
|
+
The same pattern applies to anything else master-key-only that you
|
|
862
|
+
want a client to trigger — see §12 for the full master-only matrix.
|
|
863
|
+
|
|
864
|
+
---
|
|
865
|
+
|
|
866
|
+
## 9. Analytics
|
|
867
|
+
|
|
868
|
+
`POST /events/<name>` is a public-writable surface and the SDK relays it
|
|
869
|
+
without requiring auth. The top-level `Parse.track_event` shortcut takes
|
|
870
|
+
dimensions as a keyword so Ruby 3 keyword-separation doesn't swallow them
|
|
871
|
+
into `**opts`:
|
|
872
|
+
|
|
873
|
+
```ruby
|
|
874
|
+
Parse.track_event("search",
|
|
875
|
+
dimensions: { priceRange: "1000-1500", source: "ios", dayType: "weekday" }
|
|
876
|
+
)
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
Threaded with a session token (or any other request-layer option):
|
|
880
|
+
|
|
881
|
+
```ruby
|
|
882
|
+
Parse.track_event("search",
|
|
883
|
+
dimensions: { source: "ios" },
|
|
884
|
+
session_token: me.session_token,
|
|
885
|
+
use_master_key: false,
|
|
886
|
+
)
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
If you call `Parse.client.send_analytics` directly, the dimensions must be
|
|
890
|
+
the second **positional** argument — passing them as bare keywords would
|
|
891
|
+
also be absorbed by `**opts`:
|
|
892
|
+
|
|
893
|
+
```ruby
|
|
894
|
+
Parse.client.send_analytics(
|
|
895
|
+
"search",
|
|
896
|
+
{ priceRange: "1000-1500", source: "ios" }, # positional Hash
|
|
897
|
+
session_token: me.session_token, use_master_key: false,
|
|
898
|
+
)
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
Parse Server's default `analyticsAdapter` is a no-op — events are accepted
|
|
902
|
+
but neither persisted nor queryable through the SDK. (The legacy parse.com
|
|
903
|
+
eight-dimension cap does NOT apply to Parse Server out of the box; if you
|
|
904
|
+
configure a custom adapter, it decides whether to cap and how.) For
|
|
905
|
+
queryable analytics, define a `Parse::Object` subclass and write rows;
|
|
906
|
+
see the "Analytics" section of `docs/usage_guide.md`.
|
|
907
|
+
|
|
908
|
+
Parse Server also accepts `at:` for backfilling the event timestamp; pass
|
|
909
|
+
it inside the dimensions hash so it reaches the POST body:
|
|
910
|
+
|
|
911
|
+
```ruby
|
|
912
|
+
Parse.track_event("session_start",
|
|
913
|
+
dimensions: { at: (Time.now - 60).utc.iso8601, platform: "test_harness" }
|
|
914
|
+
)
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
---
|
|
918
|
+
|
|
919
|
+
## 10. Cloud Config
|
|
920
|
+
|
|
921
|
+
`GET /config` returns the app's Cloud Config. Parse Server **automatically
|
|
922
|
+
strips entries whose `masterKeyOnly` flag is true** when the caller is
|
|
923
|
+
not the master key — the client never sees those values.
|
|
924
|
+
|
|
925
|
+
```ruby
|
|
926
|
+
Parse.client.config! # force fetch
|
|
927
|
+
Parse.client.config["theme"] # public key, visible
|
|
928
|
+
Parse.client.config["api_secret"] # nil — masterKeyOnly entry, stripped
|
|
929
|
+
Parse.client.master_key_only # {} for non-master callers
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
`PUT /config` is master-key-only. From client mode `Parse.client.update_config(…)`
|
|
933
|
+
either returns false or raises an auth-class `Parse::Error`. The SDK
|
|
934
|
+
does not silently downgrade or retry the write.
|
|
935
|
+
|
|
936
|
+
---
|
|
937
|
+
|
|
938
|
+
## 11. LiveQuery
|
|
939
|
+
|
|
940
|
+
LiveQuery is opt-in in the SDK because it opens a WebSocket egress
|
|
941
|
+
surface that operators should consciously enable:
|
|
942
|
+
|
|
943
|
+
```ruby
|
|
944
|
+
Parse.live_query_enabled = true
|
|
945
|
+
require "parse/live_query"
|
|
946
|
+
|
|
947
|
+
client = Parse::LiveQuery::Client.new(
|
|
948
|
+
url: "wss://parse.example.com/parse",
|
|
949
|
+
application_id: "MY_APP_ID",
|
|
950
|
+
client_key: "MY_REST_API_KEY",
|
|
951
|
+
master_key: nil, # explicit — see below
|
|
952
|
+
auto_connect: true,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
sub = client.subscribe(
|
|
956
|
+
"Post",
|
|
957
|
+
where: { author: me },
|
|
958
|
+
session_token: me.session_token,
|
|
959
|
+
)
|
|
960
|
+
sub.on(:create) { |row| handle_new(row) }
|
|
961
|
+
sub.on(:update) { |row| handle_update(row) }
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
Subscriptions are scoped by `session_token` and ACL is enforced
|
|
965
|
+
server-side on every event before it goes out the WebSocket — Bob will
|
|
966
|
+
not receive an event for an ACL-private row Alice creates, even if his
|
|
967
|
+
subscription matches the `where` clause.
|
|
968
|
+
|
|
969
|
+
> **Configuration tip.** `Parse::LiveQuery::Client.new` reads
|
|
970
|
+
> `master_key` from configuration if you omit it. Pass `master_key: nil`
|
|
971
|
+
> **explicitly** in client builds — the SDK preserves a sentinel value
|
|
972
|
+
> internally so it can tell "not provided" apart from "explicitly nil,"
|
|
973
|
+
> and the latter is the only safe choice in a client context.
|
|
974
|
+
|
|
975
|
+
---
|
|
976
|
+
|
|
977
|
+
## 12. Endpoints that fail closed in client mode
|
|
978
|
+
|
|
979
|
+
These exist for completeness — they ALL require the master key and the
|
|
980
|
+
SDK will fail loudly (raise or return an unsuccessful response) when
|
|
981
|
+
you call them without it:
|
|
982
|
+
|
|
983
|
+
| Endpoint | SDK call | Why master-only |
|
|
984
|
+
|---------------------------|-----------------------------------------|-------------------------------------------------------|
|
|
985
|
+
| `POST /aggregate/<Class>` | `Parse.client.aggregate_pipeline(…)` | Bypasses ACL/CLP/`protectedFields` server-side |
|
|
986
|
+
| `GET /schemas` | `Parse.client.schemas` | Schema introspection is admin-only |
|
|
987
|
+
| `PUT /schemas/<Class>` | `Parse.client.update_schema(…)` | Schema mutation is admin-only |
|
|
988
|
+
| `PUT /config` | `Parse.client.update_config(…)` | Config mutation is admin-only |
|
|
989
|
+
| `_Role` mutation | `Parse.client.update_object("_Role", …)`| Default CLP locks `_Role` writes to master |
|
|
990
|
+
| Cross-user `_User` write | `Parse.client.update_object("_User", o)`| ACL on `_User` rows blocks cross-user writes |
|
|
991
|
+
| `_Session` enumeration | `Parse.client.find_objects("_Session")` | Scoped to caller; anon gets rejected; no master = no full list |
|
|
992
|
+
|
|
993
|
+
Trying to call any of these without master should be treated as a code
|
|
994
|
+
smell, not a thing to work around. If you find yourself wanting to: the
|
|
995
|
+
correct fix is almost always (a) put the operation behind a cloud
|
|
996
|
+
function that runs server-side with `useMasterKey`, then call that
|
|
997
|
+
cloud function from the client, or (b) move the work to a privileged
|
|
998
|
+
worker process that's separate from your client deployment.
|
|
999
|
+
|
|
1000
|
+
---
|
|
1001
|
+
|
|
1002
|
+
## 13. Error handling — the response shape
|
|
1003
|
+
|
|
1004
|
+
The SDK has two error paths and you need to be aware of both:
|
|
1005
|
+
|
|
1006
|
+
* **HTTP-level errors (401/403/5xx).** These come back as `Parse::Error`
|
|
1007
|
+
subclasses and `raise`. Wrap calls that might hit auth-class failures
|
|
1008
|
+
in `begin/rescue Parse::Error => e`.
|
|
1009
|
+
* **Parse-protocol errors (`code: 101` etc.).** These return a
|
|
1010
|
+
`Parse::Response` with `response.success?` false and the message on
|
|
1011
|
+
`response.error`. They do **not** raise. The most common one is the
|
|
1012
|
+
CLP/ACL denial — `"Permission denied"`, `"Object not found"` (Parse
|
|
1013
|
+
Server's uniform shape for "you can't see it OR you can't touch it"),
|
|
1014
|
+
or `"Insufficient auth"`.
|
|
1015
|
+
|
|
1016
|
+
Robust client code checks both:
|
|
1017
|
+
|
|
1018
|
+
```ruby
|
|
1019
|
+
begin
|
|
1020
|
+
response = Parse.client.update_object(
|
|
1021
|
+
"Post", id, { "title" => "v2" },
|
|
1022
|
+
session_token: me.session_token, use_master_key: false,
|
|
1023
|
+
)
|
|
1024
|
+
if response.success?
|
|
1025
|
+
handle_ok(response.result)
|
|
1026
|
+
else
|
|
1027
|
+
handle_denied(response.error) # CLP/ACL rejection — not an exception
|
|
1028
|
+
end
|
|
1029
|
+
rescue Parse::Error::InvalidSessionTokenError => e
|
|
1030
|
+
prompt_login_again(e) # token revoked / expired
|
|
1031
|
+
rescue Parse::Error => e
|
|
1032
|
+
log_and_surface(e) # HTTP-level or transport failure
|
|
1033
|
+
end
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
A bare `assert_raises(Parse::Error)` around a CLP rejection will be
|
|
1037
|
+
silently wrong — the call returns an unsuccessful response, doesn't
|
|
1038
|
+
raise. The test suite codifies this; production code should too.
|
|
1039
|
+
|
|
1040
|
+
---
|
|
1041
|
+
|
|
1042
|
+
## 14. Putting it together
|
|
1043
|
+
|
|
1044
|
+
A complete client-side write that respects ACL, threads auth, and
|
|
1045
|
+
handles both error shapes:
|
|
1046
|
+
|
|
1047
|
+
```ruby
|
|
1048
|
+
require "parse/stack"
|
|
1049
|
+
|
|
1050
|
+
Parse.setup(
|
|
1051
|
+
server_url: ENV.fetch("PARSE_SERVER_URL"),
|
|
1052
|
+
app_id: ENV.fetch("PARSE_APP_ID"),
|
|
1053
|
+
api_key: ENV.fetch("PARSE_REST_KEY"),
|
|
1054
|
+
master_key: nil,
|
|
1055
|
+
logging: false,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
raise "client builds must not ship master key" if Parse.client.master_key.present?
|
|
1059
|
+
|
|
1060
|
+
class Note < Parse::Object
|
|
1061
|
+
parse_class "Note"
|
|
1062
|
+
acl_policy :owner_else_private
|
|
1063
|
+
property :body, :string
|
|
1064
|
+
belongs_to :author, as: :user
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
def create_note(username:, password:, body:)
|
|
1068
|
+
me = Parse::User.login(username, password)
|
|
1069
|
+
return [:auth_failed, nil] unless me
|
|
1070
|
+
|
|
1071
|
+
note = Note.new(body: body, author: me)
|
|
1072
|
+
# owner_else_private resolves :author → stamps ACL{ me.id => rw }
|
|
1073
|
+
begin
|
|
1074
|
+
if note.save(session: me.session_token)
|
|
1075
|
+
[:ok, note]
|
|
1076
|
+
else
|
|
1077
|
+
[:rejected, note.errors]
|
|
1078
|
+
end
|
|
1079
|
+
rescue Parse::Error => e
|
|
1080
|
+
[:error, e]
|
|
1081
|
+
end
|
|
1082
|
+
end
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
That's the full shape. No master key in sight, no implicit ambient
|
|
1086
|
+
auth, every call carries its session, both error paths handled
|
|
1087
|
+
explicitly.
|
|
1088
|
+
|
|
1089
|
+
---
|
|
1090
|
+
|
|
1091
|
+
## 15. Audit logging — what gets redacted, what doesn't
|
|
1092
|
+
|
|
1093
|
+
When `Parse.logging` is enabled (or you've installed a custom logger
|
|
1094
|
+
on `Parse::Client`), the request/response middleware writes a record
|
|
1095
|
+
of every HTTP call the SDK makes. That log is **operational data**
|
|
1096
|
+
— it sits in your application log stream, gets shipped to whatever
|
|
1097
|
+
log aggregator you use, and is readable by anyone with access to
|
|
1098
|
+
that aggregator. The SDK assumes the log stream is **less privileged
|
|
1099
|
+
than the Parse Server itself** and redacts accordingly.
|
|
1100
|
+
|
|
1101
|
+
### 15.1 What is automatically redacted
|
|
1102
|
+
|
|
1103
|
+
`Parse::Middleware::BodyBuilder` runs two passes over every logged
|
|
1104
|
+
request and response — a key-name-based scrub (`scrub_sensitive!`)
|
|
1105
|
+
and a shape-based vector compactor (`compact_vectors!`).
|
|
1106
|
+
|
|
1107
|
+
**Body fields** — when a hash key matches any of the
|
|
1108
|
+
`SENSITIVE_FIELDS` names (case-insensitive), the entire **value**
|
|
1109
|
+
under that key is replaced with the literal string `"[FILTERED]"`.
|
|
1110
|
+
The walker recurses into nested hashes and arrays, so a sensitive
|
|
1111
|
+
key buried inside a `batch` envelope or under a deeply-nested
|
|
1112
|
+
pointer payload is still caught. The walker also detects strings
|
|
1113
|
+
that look like embedded JSON (e.g. a serialized log line stored
|
|
1114
|
+
back as a field value) and re-runs the scrub on them.
|
|
1115
|
+
|
|
1116
|
+
Sensitive key names — matched case-insensitively:
|
|
1117
|
+
|
|
1118
|
+
| Key name | Replaced with |
|
|
1119
|
+
|-----------------------------------------|-----------------|
|
|
1120
|
+
| `password` | `"[FILTERED]"` |
|
|
1121
|
+
| `token`, `sessionToken`, `session_token`| `"[FILTERED]"` |
|
|
1122
|
+
| `access_token`, `refreshToken`, `refresh_token` | `"[FILTERED]"` |
|
|
1123
|
+
| `authData` (the entire provider block) | `"[FILTERED]"` |
|
|
1124
|
+
| `masterKey`, `master_key` | `"[FILTERED]"` |
|
|
1125
|
+
| `apiKey`, `api_key` | `"[FILTERED]"` |
|
|
1126
|
+
| `clientKey`, `client_key` | `"[FILTERED]"` |
|
|
1127
|
+
| `javascriptKey`, `javascript_key` | `"[FILTERED]"` |
|
|
1128
|
+
|
|
1129
|
+
Two notes on the `authData` row: (a) the WHOLE provider block is
|
|
1130
|
+
replaced — `authData.anonymous.id`, `authData.facebook.access_token`,
|
|
1131
|
+
`authData.apple.id_token` all disappear together, so OAuth tokens
|
|
1132
|
+
never escape into logs even on a provider the SDK doesn't know
|
|
1133
|
+
about yet; (b) the redactor catches `authData` whether it appears
|
|
1134
|
+
in a login payload, a signup payload, an `upgrade_anonymous!` PUT,
|
|
1135
|
+
or a passing-through `GET /users/me` response.
|
|
1136
|
+
|
|
1137
|
+
`Parse::Middleware::BodyBuilder.redact(str)` is also exposed as a
|
|
1138
|
+
last-line string-level pass that re-applies a regex over the
|
|
1139
|
+
already-scrubbed text. The regex catches the small set of cases the
|
|
1140
|
+
structural walker can miss — `password=hunter2` style query strings
|
|
1141
|
+
in URLs, sensitive values inside array elements, and any embedded
|
|
1142
|
+
text the structural pass already converted to `"[FILTERED]"` is
|
|
1143
|
+
left alone (the regex is a backstop, not a re-redactor).
|
|
1144
|
+
|
|
1145
|
+
**Request headers** — these are always replaced with `"[FILTERED]"`
|
|
1146
|
+
in debug logs, matched case-insensitively against the Faraday
|
|
1147
|
+
header keys:
|
|
1148
|
+
|
|
1149
|
+
| Header |
|
|
1150
|
+
|-----------------------------------------|
|
|
1151
|
+
| `X-Parse-Master-Key` |
|
|
1152
|
+
| `X-Parse-REST-API-Key` |
|
|
1153
|
+
| `X-Parse-Session-Token` |
|
|
1154
|
+
| `X-Parse-JavaScript-Key` |
|
|
1155
|
+
| `Authorization` |
|
|
1156
|
+
| `Cookie` |
|
|
1157
|
+
| `X-Api-Key` |
|
|
1158
|
+
| `OpenAI-Organization`, `OpenAI-Project` |
|
|
1159
|
+
| `Anthropic-Api-Key` |
|
|
1160
|
+
|
|
1161
|
+
The OpenAI/Anthropic entries cover the case where embedding-provider
|
|
1162
|
+
HTTP traffic shares the Parse logging path — the official OpenAI auth
|
|
1163
|
+
header is `Authorization: Bearer …` (covered above), but Organization
|
|
1164
|
+
and Project IDs are account-identifying metadata operators may not
|
|
1165
|
+
want published.
|
|
1166
|
+
|
|
1167
|
+
**Vector embeddings** — see §15.3.
|
|
1168
|
+
|
|
1169
|
+
The redactor operates on a copy of the body so the live
|
|
1170
|
+
request/response objects keep their values; subsequent middleware
|
|
1171
|
+
handlers (retry, cache, error mapping, model hydration) see the real
|
|
1172
|
+
data, only the log line is scrubbed.
|
|
1173
|
+
|
|
1174
|
+
### 15.2 What is **not** redacted
|
|
1175
|
+
|
|
1176
|
+
The redactor is deliberately conservative. These ride through to the
|
|
1177
|
+
log stream as-is, and you should treat your log stream's access
|
|
1178
|
+
controls accordingly:
|
|
1179
|
+
|
|
1180
|
+
* **Class-level data values** — every saved/fetched row's columns
|
|
1181
|
+
end up in the log when `Parse.logging` is at debug level. If you
|
|
1182
|
+
store PII (email, phone, addresses, profile body text), it lands
|
|
1183
|
+
in logs in the clear. The SDK can't tell PII from non-PII at this
|
|
1184
|
+
layer.
|
|
1185
|
+
* **Query bodies** — every `where:` clause is logged verbatim. A
|
|
1186
|
+
query like `Post.where(authorEmail: "ada@…")` puts the email
|
|
1187
|
+
in the log.
|
|
1188
|
+
* **Cloud function arguments and return values** — `Parse.call_function`
|
|
1189
|
+
arguments and the cloud function's response body are logged in
|
|
1190
|
+
full. If your cloud function accepts or returns a secret, redact
|
|
1191
|
+
it before logging.
|
|
1192
|
+
* **File names, file URLs, file sizes.** `POST /files/<name>` and
|
|
1193
|
+
the resulting `Parse.File` URL are logged. The bytes themselves
|
|
1194
|
+
are not (the body builder uses a `…` placeholder for binary
|
|
1195
|
+
payloads).
|
|
1196
|
+
* **Email addresses on `_User` rows.** Email is treated as ordinary
|
|
1197
|
+
column data — not redacted at this layer. Use Parse Server's
|
|
1198
|
+
`protectedFields` if you want it stripped on cross-user reads.
|
|
1199
|
+
|
|
1200
|
+
### 15.3 Vector embeddings
|
|
1201
|
+
|
|
1202
|
+
Embeddings are a special case worth calling out — they are caught
|
|
1203
|
+
by **shape**, not by key name. A 1536-float embedding inlines as
|
|
1204
|
+
~25 KB per logged row, and embeddings are *reversible-by-similarity*
|
|
1205
|
+
against a public model: an attacker who scrapes operator logs can
|
|
1206
|
+
recover topic, sentiment, and sometimes near-verbatim short text
|
|
1207
|
+
from the raw vector. The `compact_vectors!` pass walks the logged
|
|
1208
|
+
body and replaces any numeric-only `Array` of length ≥ 32 with the
|
|
1209
|
+
single placeholder string `"<vector dims=N>"`. Coverage:
|
|
1210
|
+
|
|
1211
|
+
* `$vectorSearch.queryVector` in aggregate request bodies.
|
|
1212
|
+
* `:vector` field values in `POST` / `PUT` request bodies.
|
|
1213
|
+
* `Klass.find_similar(vector: …)` request bodies.
|
|
1214
|
+
* Batched embedding-provider response shapes (when you've installed
|
|
1215
|
+
your own provider that logs through this middleware).
|
|
1216
|
+
|
|
1217
|
+
The 32-element threshold sits well below every common embedding
|
|
1218
|
+
width (BGE-small 384, Cohere 1024, OpenAI small 1536, OpenAI large
|
|
1219
|
+
3072) and well above any normal Parse `Array` property — tags,
|
|
1220
|
+
role pointer lists, attachment id arrays. The all-Numeric guard
|
|
1221
|
+
prevents the rule from mangling long string-array or
|
|
1222
|
+
object-array properties.
|
|
1223
|
+
|
|
1224
|
+
### 15.4 Master-key context — what's logged regardless
|
|
1225
|
+
|
|
1226
|
+
A few outbound calls log enough metadata to identify a request even
|
|
1227
|
+
under redaction:
|
|
1228
|
+
|
|
1229
|
+
* HTTP method + URL path are always logged.
|
|
1230
|
+
* Request `objectId` (path segment) is always logged.
|
|
1231
|
+
* Response status code and Parse `code` field are always logged.
|
|
1232
|
+
|
|
1233
|
+
This is deliberate — without these, an audit trail can't link a
|
|
1234
|
+
user complaint ("I lost my draft at 14:02") to a server-side action.
|
|
1235
|
+
The redactor's job is to keep secrets and reversible identifiers
|
|
1236
|
+
out of the log, not to anonymize the trail itself.
|
|
1237
|
+
|
|
1238
|
+
### 15.5 Custom redaction
|
|
1239
|
+
|
|
1240
|
+
If you store sensitive values in column data and need them stripped
|
|
1241
|
+
before they hit your log aggregator, the cleanest hook is a custom
|
|
1242
|
+
middleware in front of `BodyBuilder` — or, if you only need to
|
|
1243
|
+
filter the final formatted log line, a `Logger` subclass that
|
|
1244
|
+
overrides `add` and applies a regex strip. Don't try to mutate the
|
|
1245
|
+
`Parse::Response` body to redact inbound data; downstream model
|
|
1246
|
+
hydration runs against that body and needs the real values.
|
|
1247
|
+
|
|
1248
|
+
```ruby
|
|
1249
|
+
class RedactingLogger < Logger
|
|
1250
|
+
SENSITIVE = /"(stripeCustomerId|ssn|apiKey)":"[^"]+"/
|
|
1251
|
+
|
|
1252
|
+
def add(severity, message = nil, progname = nil, &block)
|
|
1253
|
+
if message.is_a?(String)
|
|
1254
|
+
message = message.gsub(SENSITIVE, '"\1":"<redacted>"')
|
|
1255
|
+
end
|
|
1256
|
+
super
|
|
1257
|
+
end
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
Parse.setup(
|
|
1261
|
+
server_url: "…", app_id: "…", api_key: "…",
|
|
1262
|
+
logger: RedactingLogger.new($stdout),
|
|
1263
|
+
logging: :debug,
|
|
1264
|
+
)
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
The custom-field redaction is **your** responsibility — the SDK
|
|
1268
|
+
only knows about the auth surface and the embedding surface
|
|
1269
|
+
because those are stable across deployments. Anything app-specific
|
|
1270
|
+
(tenant ids, payment metadata, internal account numbers) needs an
|
|
1271
|
+
app-specific filter.
|
|
1272
|
+
|
|
1273
|
+
---
|
|
1274
|
+
|
|
1275
|
+
## 16. Client-mode `Parse::Agent` (v5.0)
|
|
1276
|
+
|
|
1277
|
+
`Parse::Agent` follows the same posture as the rest of this guide. When
|
|
1278
|
+
constructed against a no-master client with a session token, it enters
|
|
1279
|
+
*client mode* and restricts itself to a session-token REST allowlist
|
|
1280
|
+
(`list_tools`, `get_object`, `get_objects`, `query_class`,
|
|
1281
|
+
`count_objects`, `get_sample_objects`, plus the mutation trio
|
|
1282
|
+
`create_object` / `update_object` / `delete_object` behind an
|
|
1283
|
+
`allow_mutations:` gate). Everything that needs master-key REST
|
|
1284
|
+
(`aggregate`, `atlas_*`, `get_all_schemas`) or a direct MongoDB
|
|
1285
|
+
connection (mongo-direct aggregations, vector search) is refused at the
|
|
1286
|
+
dispatch ceiling.
|
|
1287
|
+
|
|
1288
|
+
```ruby
|
|
1289
|
+
agent = Parse::Agent.new(session_token: me.session_token)
|
|
1290
|
+
agent.client_mode? # => true
|
|
1291
|
+
agent.allow_mutations? # => false (default)
|
|
1292
|
+
|
|
1293
|
+
agent.execute(:query_class, class_name: "Post", limit: 10) # ACL-enforced by Parse Server
|
|
1294
|
+
|
|
1295
|
+
writer = Parse::Agent.new(session_token: me.session_token, allow_mutations: true)
|
|
1296
|
+
writer.execute(:create_object, class_name: "Post", fields: { title: "Hi" })
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
`acl_user:` and `acl_role:` are refused at construction on a no-master
|
|
1300
|
+
client — they're SDK-side identity assertions that require the
|
|
1301
|
+
master-key mongo-direct path to enforce. Use `session_token:` as the
|
|
1302
|
+
identity instead. Full reference (custom tools with `client_safe: true`,
|
|
1303
|
+
sub-agent inheritance, refusal-message shapes) is in
|
|
1304
|
+
[`docs/mcp_guide.md` § Client Mode](mcp_guide.md#client-mode--session-token-only-agents-v50).
|
|
1305
|
+
|
|
1306
|
+
---
|
|
1307
|
+
|
|
1308
|
+
## 17. Cross-references
|
|
1309
|
+
|
|
1310
|
+
* `test/lib/parse/client_rest_auth_integration_test.rb` — signup, login, logout, current_user, MFA surface
|
|
1311
|
+
* `test/lib/parse/client_rest_crud_integration_test.rb` — save, fetch, update, destroy, query, include, ACL
|
|
1312
|
+
* `test/lib/parse/client_rest_acl_integration_test.rb` — ACL policies, wire shape, cross-user `_User` write
|
|
1313
|
+
* `test/lib/parse/client_rest_roles_integration_test.rb` — role membership, hierarchy direction, `_Role` write block
|
|
1314
|
+
* `test/lib/parse/client_rest_clp_anonymous_integration_test.rb` — CLP enforcement and `protectedFields`
|
|
1315
|
+
* `test/lib/parse/client_rest_files_integration_test.rb` — authed + anonymous file upload behavior
|
|
1316
|
+
* `test/lib/parse/client_rest_analytics_integration_test.rb` — `/events` round-trip under client mode
|
|
1317
|
+
* `test/lib/parse/client_rest_cloud_config_integration_test.rb` — `/config` visibility and write rejection
|
|
1318
|
+
* `test/lib/parse/client_rest_forbidden_paths_integration_test.rb` — master-only endpoints fail closed
|
|
1319
|
+
* `test/lib/parse/client_livequery_integration_test.rb` — LiveQuery handshake without master key
|
|
1320
|
+
* `test/support/client_mode_helper.rb` — the test harness pattern these tests share
|