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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. 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