parse-stack-next 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -1
@@ -0,0 +1,418 @@
1
+ # Cloud Code Webhooks Guide
2
+
3
+ Webhooks are how `parse-stack-next` runs **server-side** trigger logic. They are
4
+ the bridge between Parse Server and your Ruby code: Parse Server calls back into
5
+ a Ruby Rack app on a matching trigger, and your model's ActiveModel callbacks
6
+ (and any webhook blocks) run there.
7
+
8
+ This is a server-side-only concern. A pure client (or a server with no
9
+ registered webhooks) runs all of its trigger logic locally in ActiveModel and
10
+ nothing inside Parse Server.
11
+
12
+ ## Why register a webhook at all
13
+
14
+ A `Parse::Object`'s ActiveModel callbacks run in the process that initiates the
15
+ save:
16
+
17
+ - A **Ruby-initiated** save (this SDK) runs `before_save`, `after_create`, etc.
18
+ locally, before/after the REST call.
19
+ - A save from a **non-Ruby client** — the JS/Swift SDKs, a raw REST call, or the
20
+ Parse Dashboard — never touches your Ruby process. That trigger logic is
21
+ simply skipped server-side.
22
+
23
+ Registering a webhook closes that gap. Once Parse Server has a `beforeSave`
24
+ webhook for a class, it calls your Ruby app on every save from every client, and
25
+ your callbacks run server-side for all of them.
26
+
27
+ **The rule:** your ActiveModel logic applies to non-Ruby clients **only if the
28
+ webhook is registered.**
29
+
30
+ ## ActiveModel hooks vs Parse Server triggers
31
+
32
+ The SDK exposes the full ActiveModel lifecycle on every `Parse::Object`. Parse
33
+ Server, separately, exposes a fixed set of webhook trigger types. They are not
34
+ one-to-one — the SDK maps between them.
35
+
36
+ ### ActiveModel callbacks (Ruby side)
37
+
38
+ | Callback | Fires |
39
+ |----------|-------|
40
+ | `before_validation` / `after_validation` | around local validation |
41
+ | `before_save` / `after_save` | around every save (create **and** update) |
42
+ | `before_create` / `after_create` | around the first save of a new object |
43
+ | `before_update` / `after_update` | around saves of an existing object |
44
+ | `before_destroy` / `after_destroy` | around delete |
45
+
46
+ ### Parse Server webhook trigger types (server side)
47
+
48
+ | Trigger | className | Notes |
49
+ |---------|-----------|-------|
50
+ | `beforeSave` / `afterSave` | a class | create **and** update |
51
+ | `beforeDelete` / `afterDelete` | a class | |
52
+ | `beforeFind` / `afterFind` | a class | |
53
+ | `beforeLogin` / `afterLogin` | `_User` | login-side hooks |
54
+ | `afterLogout` | `_Session` | |
55
+ | `beforePasswordResetRequest` | `_User` | |
56
+ | `beforeSave` / `afterSave` / `beforeDelete` / `beforeFind` / `afterFind` | `@File` | file triggers |
57
+ | `beforeConnect` | `@Connect` | LiveQuery connection (connection-global) |
58
+ | `beforeSubscribe` / `afterEvent` | a class | LiveQuery subscription / events |
59
+
60
+ ### How they relate
61
+
62
+ - **`beforeSave` / `afterSave` carry the create variants.** Parse Server has **no
63
+ `beforeCreate` / `afterCreate` trigger** — it rejects them. The SDK runs your
64
+ `before_create` / `after_create` callbacks *inside* the `beforeSave` /
65
+ `afterSave` handler, gated on whether the object is new. So **registering a
66
+ `beforeSave` webhook enables both `before_save` and `before_create`**;
67
+ registering `afterSave` enables both `after_save` and `after_create`.
68
+
69
+ Asking for a create webhook fails fast with guidance:
70
+
71
+ ```ruby
72
+ Post.webhook(:after_create) { … }
73
+ # ArgumentError: There is no after_create webhook. Register `webhook :after_save`
74
+ # instead — your after_create ActiveModel callbacks run inside the after_save
75
+ # handler for new objects (registering after_save enables BOTH the after_save
76
+ # and after_create callbacks).
77
+ ```
78
+
79
+ - **Trigger order is honored.** Within the save handler the SDK runs callbacks in
80
+ ActiveModel order: `before_save` then `before_create` on the way in,
81
+ `after_create` then `after_save` on the way out.
82
+
83
+ - **`@File` and `@Connect` are pseudo-classes.** File triggers register against
84
+ the `@File` className; the connection-global LiveQuery trigger uses `@Connect`.
85
+ The SDK accepts both for the full register/fetch/delete lifecycle.
86
+
87
+ - **`beforeFind` / `afterFind` are result-side, not object-side.** Unlike the
88
+ save/delete triggers, a find payload carries no single `object` — `beforeFind`
89
+ exposes the incoming `query` (via `payload.query`) and `afterFind` exposes the
90
+ matched rows (via `payload.objects`). And unlike `afterSave` (whose return
91
+ value Parse Server ignores), **`afterFind` is result-rewriting**: whatever the
92
+ handler returns *replaces* the rows sent to the client, so it can filter or
93
+ redact results. It also adds a webhook round-trip to every matching query, so
94
+ register it deliberately.
95
+
96
+ One non-obvious detail the SDK handles for you: **Parse Server does not put the
97
+ class name anywhere in the find payload body** — the matched objects omit
98
+ `className` and there is no top-level one. The SDK derives the class from the
99
+ webhook URL path (`<endpoint>/<trigger>/<className>`) so your `afterFind` /
100
+ `beforeFind` block routes correctly and `payload.parse_class` resolves. (If you
101
+ build a `Payload` yourself in a test, pass the class as the second argument:
102
+ `Parse::Webhooks::Payload.new(body, "MyClass")`.)
103
+
104
+ Because the class is resolved from the route, declared `:vector` columns are
105
+ stripped from `afterFind` `payload.objects` by default, exactly as they are
106
+ from `object`/`original`/`update` on the other triggers (a
107
+ `vector_visibility :public` class keeps them). One consequence to keep in
108
+ mind: an `afterFind` handler that returns `payload.objects` to pass results
109
+ through passes the *vector-scrubbed* rows on to the client — which matches the
110
+ `as_json` default (an `owner_only` class never exposes vectors anyway). Return
111
+ your own array if you need different columns.
112
+
113
+ - **Auth triggers (`beforeLogin` / `afterLogin` / `afterLogout` /
114
+ `beforePasswordResetRequest`) and LiveQuery triggers (`beforeConnect` /
115
+ `beforeSubscribe` / `afterEvent`) are routed as first-class shapes** — they
116
+ are not object save/delete triggers, so **none of them run ActiveModel
117
+ `save` / `create` / `destroy` callbacks**, even the login/logout/reset ones
118
+ that carry a `_User` or `_Session`.
119
+
120
+ Identify them with the matching predicates — `before_login?`, `after_login?`,
121
+ `after_logout?`, `before_password_reset_request?`, `before_connect?`,
122
+ `before_subscribe?`, `after_event?` — or the category helpers `auth_trigger?`
123
+ / `live_query_trigger?`. Useful accessors by shape:
124
+
125
+ | Trigger | what the payload carries |
126
+ |---------|--------------------------|
127
+ | `beforeLogin` | the user being authenticated as **`payload.parse_object`** (a `_User`). `payload.user` is **`nil`** — auth isn't complete yet. |
128
+ | `afterLogin` | both `payload.parse_object` and `payload.user` (the now-authenticated user). |
129
+ | `afterLogout` | the session as `payload.parse_object` (a `_Session`). |
130
+ | `beforePasswordResetRequest` | the target user as `payload.parse_object`. |
131
+ | `beforeConnect` | connection-global: no object; the caller token (if any) in `payload.session_token`; counts in `payload.clients` / `payload.subscriptions`. |
132
+ | `beforeSubscribe` | shaped like `beforeFind` — `payload.query` / `payload.parse_query`; className comes from the route. Caller token in `payload.session_token`. |
133
+ | `afterEvent` | the event type in `payload.event` (`create` / `enter` / `update` / `leave` / `delete`), plus `payload.object` / `payload.original`. |
134
+
135
+ > The login footgun: during `beforeLogin` reach for `payload.parse_object`,
136
+ > **not** `payload.user` (which is `nil`). For connect/subscribe the live
137
+ > session token is at the top level of the payload, not nested under a user —
138
+ > the SDK captures it into `payload.session_token` (so `payload.user_client` /
139
+ > `payload.user_agent` work) and keeps it out of `as_json` and the request log.
140
+
141
+ **Response contract — what you return matters only for the `before*` ones.**
142
+ Parse Server **ignores the response body for all seven** of these triggers
143
+ (its webhook response handler resolves `{}` regardless). The *only* way a
144
+ handler affects the operation is by **rejecting** it, and only the `before*`
145
+ variants can be rejected (an `after*` trigger fires after the fact):
146
+
147
+ ```ruby
148
+ Parse::Webhooks.route(:before_login, "_User") do |payload|
149
+ error!("account suspended") if payload.parse_object.suspended? # denies login
150
+ # returning false also denies (mapped to the error response); anything else
151
+ # — including the user object — succeeds as a no-op
152
+ end
153
+
154
+ Parse::Webhooks.route(:after_event, "Post") do |payload|
155
+ AuditLog.record(payload.event, payload.parse_id) # observe-only; return value ignored
156
+ end
157
+ ```
158
+
159
+ Note the asymmetry with `before_save`: Parse Server treats a `{success:false}`
160
+ body as **allow** (only an `{error}` body rejects). So "return `false` to deny
161
+ login" only works because the SDK converts that `false` into an error response
162
+ for you. `error!(message)` is the explicit, message-carrying form.
163
+
164
+ **LiveQuery delivery caveat.** `beforeConnect` / `beforeSubscribe` /
165
+ `afterEvent` fire inside the LiveQuery server. They are delivered to an HTTP
166
+ webhook **only in a co-located, single-process LiveQuery setup**; with a
167
+ separate LiveQuery server they are in-process (`Parse.Cloud`) only.
168
+ `beforeConnect` in particular carries a live client handle that does not
169
+ serialize over HTTP, so it is effectively in-process-only. Register them when
170
+ you know your topology supports it.
171
+
172
+ ## Defining and registering webhooks
173
+
174
+ ```ruby
175
+ Parse::Webhooks.key = ENV.fetch("PARSE_WEBHOOK_KEY") # matches Parse Server's webhookKey
176
+
177
+ class Post < Parse::Object
178
+ property :title, :string
179
+
180
+ before_save :normalize # runs server-side once beforeSave is registered
181
+ after_create :index_for_search # runs inside the afterSave handler for new posts
182
+
183
+ webhook :before_save do # optional block, in addition to callbacks
184
+ parse_object # return the object (or `false` to halt the save)
185
+ end
186
+ end
187
+ ```
188
+
189
+ Register with Parse Server (once, at deploy — requires the master key).
190
+ `endpoint` is the public HTTPS URL where the Rack app is reachable:
191
+
192
+ ```ruby
193
+ Parse::Webhooks.register_functions!("https://hooks.example.com/webhooks")
194
+ Parse::Webhooks.register_triggers!("https://hooks.example.com/webhooks")
195
+ ```
196
+
197
+ Mount the Rack app (`config.ru`):
198
+
199
+ ```ruby
200
+ require_relative "app/webhooks"
201
+ run Parse::Webhooks
202
+ ```
203
+
204
+ See [`examples/webhook_server.rb`](../examples/webhook_server.rb) for a complete,
205
+ runnable setup.
206
+
207
+ ## Auditing trigger coverage
208
+
209
+ The wiring above has three independent moving parts, and a callback runs
210
+ server-side only when all three line up:
211
+
212
+ 1. the model's **ActiveModel callback** (`after_save :send_email`),
213
+ 2. a **local webhook route** so the router has a handler to run (the
214
+ `webhook :after_save` block, or `Parse::Webhooks.route(:after_save, "Post")`),
215
+ 3. the **server trigger** registered with Parse Server (`register_triggers!`),
216
+ so Parse Server actually POSTs to your app.
217
+
218
+ Declaring the callback alone does nothing for a non-Ruby client — the save
219
+ never touches your Ruby process. It is easy for these three to drift: a new
220
+ `after_save` callback with no block, a `webhook` block you never registered, or
221
+ a stale server trigger pointing at a class whose block was removed.
222
+
223
+ `Parse::Webhooks.trigger_audit` cross-references all three across every
224
+ registered class and reports the gaps. The server comparison reads the
225
+ master-key-only `hooks/triggers` endpoint, so it needs a master-key client;
226
+ pass `network: false` to audit callbacks against local routes only.
227
+
228
+ ```ruby
229
+ puts Parse::Webhooks.trigger_audit(pretty: true) # human-readable summary
230
+ report = Parse::Webhooks.trigger_audit # Hash report
231
+ Parse::Webhooks.trigger_audit(network: false) # local-only, no master key
232
+ ```
233
+
234
+ The audit emits four kinds of findings:
235
+
236
+ - **`callbacks_inert`** — a model has callbacks mapping to a trigger
237
+ (`after_save` / `after_create` → `afterSave`, etc.) but the local block and/or
238
+ the server trigger is missing, so they never fire for non-Ruby clients. The
239
+ `missing:` list says which piece to add. This is the headline gap.
240
+ - **`route_not_registered`** — a local `webhook :X` block exists but the trigger
241
+ isn't on the server, so Parse Server never calls it. Fix by running
242
+ `register_triggers!`.
243
+ - **`orphan_server_trigger`** — a server trigger is registered but no local block
244
+ handles it; every matching operation pays a webhook round-trip that does
245
+ nothing.
246
+ - **`local_only_callbacks`** — informational: `before_update` / `after_update`
247
+ and `before_validation` / `after_validation` callbacks have **no** Parse Server
248
+ trigger that can run them (the webhook router runs only the save and create
249
+ chains). They fire for Ruby-initiated saves but never for non-Ruby clients,
250
+ and no registration changes that.
251
+
252
+ Wire it into CI or a deploy check to fail fast on a coverage gap:
253
+
254
+ ```ruby
255
+ inert = Parse::Webhooks.trigger_audit[:summary][:findings][:callbacks_inert].to_i
256
+ abort "Webhook coverage gaps detected" if inert.positive?
257
+ ```
258
+
259
+ ## Returning a value from a handler
260
+
261
+ A handler block runs with `self` bound to the `Parse::Webhooks::Payload`, so
262
+ inside it you can call `parse_object`, `params`, `error!`, etc. directly. The
263
+ value the handler produces is what Parse Server receives: for `before_save`,
264
+ return the (possibly mutated) `parse_object` to allow the write, or `false` /
265
+ `error!` to reject it.
266
+
267
+ You can set that value either with an explicit `return` or by letting it be the
268
+ block's last expression — both work:
269
+
270
+ ```ruby
271
+ Parse::Webhooks.route :before_save, :Post do
272
+ post = parse_object
273
+
274
+ return post if post.title.present? # explicit early return
275
+ error! "title is required" # raise to reject the save
276
+ end
277
+
278
+ # Equivalent, using the last-expression value:
279
+ Parse::Webhooks.route :before_save, :Post do
280
+ post = parse_object
281
+ post.title.present? ? post : error!("title is required")
282
+ end
283
+ ```
284
+
285
+ The legacy proc idioms remain valid too — `next value` and `break value` both
286
+ set the result. `return`, like anywhere in Ruby, ends the handler immediately,
287
+ so nothing written after it in the same block runs. To run work *after* the
288
+ response, use [`after_response`](#deferring-work-until-after-the-response)
289
+ rather than writing code after the `return`.
290
+
291
+ ## Deferring work until after the response
292
+
293
+ `payload.after_response { … }` (alias `defer`) registers a block to run **after**
294
+ the webhook response has been sent to Parse Server — off the critical path of the
295
+ save or function the client is waiting on. The handler still returns its value
296
+ synchronously (that value is the response Parse Server acts on); the deferred
297
+ block runs afterward. Use it for follow-up work that should not add latency:
298
+ search indexing, cache warming, fan-out notifications.
299
+
300
+ ```ruby
301
+ Parse::Webhooks.route :after_save, :Post do
302
+ post = parse_object
303
+ after_response { SearchIndex.reindex(post.id) } # runs after the reply is sent
304
+ post
305
+ end
306
+ ```
307
+
308
+ How it runs:
309
+
310
+ - **Under Puma or Unicorn** the block is enqueued on `rack.after_reply` and runs
311
+ once the response is flushed to the socket, on the same worker thread — so it
312
+ adds nothing to the client's round-trip.
313
+ - **On a server without `rack.after_reply`** (e.g. WEBrick) it falls back to a
314
+ detached thread per request with deferred work — there is no pool or cap, so
315
+ under high request volume those threads can accumulate. Run the webhook app
316
+ under **Puma or Unicorn in production** (both provide `rack.after_reply`, which
317
+ runs the work on the existing worker thread with no extra thread spawned); the
318
+ thread fallback is best treated as a development-server convenience.
319
+ - Multiple `after_response` blocks run in registration order, and each is
320
+ isolated — one raising affects neither the response nor the others.
321
+ - `self` inside the block is the payload, so `parse_object`, `params`, etc. are
322
+ available (it closes over the handler's scope).
323
+
324
+ Things to know before relying on it:
325
+
326
+ - **Success path only.** Deferred blocks run only when the handler produced a
327
+ successful response. If a `before_save` rejects the write (`error!`, a raise,
328
+ or returning `false`), its registered `after_response` blocks do **not** run.
329
+ - **"After the response" is not "after the row commits."** The block runs after
330
+ the *response* is flushed. For `before_save` that is before Parse Server has
331
+ committed the write; even for `after_save` the SDK does not guarantee commit
332
+ ordering relative to the deferred block. Do not rely on the persisted row being
333
+ readable inside it.
334
+ - **In-process and best-effort.** The work runs in the web worker and does not
335
+ survive a restart, crash, or deploy. For work that *must* happen — payment
336
+ capture, irreversible side effects — hand it to a durable job queue
337
+ (Sidekiq / ActiveJob) instead; `after_response` is for latency-shedding, not
338
+ durability.
339
+ - **Mounted-app only.** Deferred blocks are drained by the `Parse::Webhooks` Rack
340
+ app. Invoking a handler directly (`Parse::Webhooks.run_function`, or calling
341
+ `call_route` in a unit test) does not run them — `after_response` is a no-op
342
+ there.
343
+ - **Capturing `user_client` / `user_agent` extends the token's lifetime.** A
344
+ deferred block closes over the payload, so referencing `payload.user_client` /
345
+ `payload.user_agent` (or `payload.session_token`) keeps the caller's live
346
+ session token in memory until the block finishes — beyond the synchronous
347
+ request. That is fine and expected when the deferred work needs to act as the
348
+ caller; just don't capture them when the work doesn't need the user's
349
+ authority (use a master-key client instead), so the token isn't pinned longer
350
+ than necessary.
351
+
352
+ ## Latency: webhooks are synchronous
353
+
354
+ Every registered webhook adds a **separate, synchronous HTTP round-trip** to the
355
+ client's operation. Parse Server **waits for the webhook to return before
356
+ proceeding** — and it waits even on `afterSave`, despite the afterSave return
357
+ value being a no-op.
358
+
359
+ This has direct design consequences for `afterSave` (and `afterDelete`):
360
+
361
+ - **Enqueue, don't execute.** Treat `after_save` as a place to hand work to a
362
+ background job, not to do long-running logic inline. Anything slow here is
363
+ added latency on every save, for every client. For in-process follow-up that
364
+ doesn't need a durable queue, [`after_response`](#deferring-work-until-after-the-response)
365
+ moves it off the client's round-trip; for anything that *must* happen, use a
366
+ real job queue.
367
+ - **Avoid saving other objects during an afterSave.** Each cascading save fires
368
+ its own webhooks, which can fire more — a latency cascade. If you must, do it
369
+ in a background job, not inline in the handler.
370
+
371
+ `beforeSave` is necessarily inline (it can mutate or reject the write), so keep
372
+ it lean and deterministic.
373
+
374
+ ## Server-side dedup: two distinct mechanisms
375
+
376
+ Two different "dedup" systems protect webhook handling. They solve different
377
+ problems — don't conflate them.
378
+
379
+ ### 1. Ruby-initiated dedup (keep logic local, prevent double-runs)
380
+
381
+ When a save is initiated by **this SDK with the master key**, Parse Stack tags
382
+ the request as trusted-Ruby-initiated (an `_RB_` request-id marker plus the
383
+ master key). It has already run the model's `before_save` / `after_save` /
384
+ `after_create` ActiveModel callbacks **locally**. The webhook therefore does
385
+ **not** re-run those callbacks — that would double-fire side effects (e.g. an
386
+ `after_save :send_email` would send two emails per save).
387
+
388
+ The intent is to keep trigger logic local when possible and run it exactly once.
389
+ Note that any logic in the **webhook block itself** still runs; only the
390
+ duplicate ActiveModel callback pass is skipped. A spoofed `_RB_` marker without
391
+ the master key does not get this treatment — the callbacks run in the webhook as
392
+ usual.
393
+
394
+ ### 2. Server-initiated replay / freshness protection (inbound)
395
+
396
+ This protects the webhook endpoint against **replayed inbound POSTs** —
397
+ `lib/parse/webhooks/replay_protection.rb`:
398
+
399
+ - **Always-on body + request-id dedup.** A bounded LRU records a digest of each
400
+ `(request_id, body)`; a duplicate seen within `replay_window_seconds` is
401
+ rejected with `"Webhook replay detected."`. No cooperation from Parse Server is
402
+ required; this stops in-window replays.
403
+ - **Opt-in HMAC freshness verification.** Set a `signing_secret` and the receiver
404
+ verifies two headers:
405
+ - `X-Parse-Webhook-Timestamp` — Unix epoch seconds; requests outside
406
+ `signing_max_skew_seconds` (default 300) are rejected as stale.
407
+ - `X-Parse-Webhook-Signature` — hex HMAC-SHA256 of `"#{timestamp}.#{body}"`
408
+ keyed with the signing secret.
409
+
410
+ ```ruby
411
+ Parse::Webhooks::ReplayProtection.signing_secret = ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
412
+ Parse::Webhooks::ReplayProtection.replay_window_seconds = 120
413
+ Parse::Webhooks::ReplayProtection.signing_max_skew_seconds = 300
414
+ ```
415
+
416
+ This is **inbound** protection and is unrelated to request **idempotency**
417
+ (`X-Parse-Request-Id`), which dedups the SDK's own **outbound** retries on the
418
+ Parse Server side. Different direction, different mechanism.
@@ -0,0 +1,46 @@
1
+ # Examples
2
+
3
+ Runnable scripts that exercise `parse-stack-next` against a live Parse Server.
4
+ Each file is self-contained and reads its configuration from environment
5
+ variables. Start here:
6
+
7
+ | Script | Demonstrates | Needs |
8
+ |---|---|---|
9
+ | [`basic_server.rb`](basic_server.rb) | Privileged (master-key) setup: define models, push schema with `auto_upgrade!`, full CRUD + queries with a `belongs_to`. | app id, REST key, **master key** |
10
+ | [`basic_client.rb`](basic_client.rb) | Unprivileged client (no master key): login/signup, `with_session`, and a row-level **ACL enforcement** demo (the owner reads a record; an anonymous caller gets `nil`). | app id, REST key |
11
+ | [`live_query_listener.rb`](live_query_listener.rb) | Interactive LiveQuery console: subscribes scoped to a user's session token and prints create / update / delete events until Ctrl-C — you only "hear" what that user may read. | app id, REST key, LiveQuery URL |
12
+ | [`rag_chatbot.rb`](rag_chatbot.rb) | Retrieval-augmented generation: managed `embed`, `agent_searchable`, `semantic_search` via `Parse::Agent`, plus an OpenAI/Anthropic generation add-in. | app id, REST key, master key, `OPENAI_API_KEY` (+ Atlas) |
13
+ | [`transaction_example.rb`](transaction_example.rb) | Atomic multi-object operations via `Parse::Object.transaction`. | app id, REST key |
14
+
15
+ ## Common setup
16
+
17
+ All scripts read a Parse connection from the environment:
18
+
19
+ ```bash
20
+ export PARSE_SERVER_URL=http://localhost:1337/parse
21
+ export PARSE_APP_ID=your-app-id
22
+ export PARSE_REST_KEY=your-rest-api-key
23
+ export PARSE_MASTER_KEY=your-master-key # server-side scripts only
24
+ ```
25
+
26
+ Then run any script with the gem on the load path:
27
+
28
+ ```bash
29
+ ruby -Ilib examples/basic_server.rb
30
+ ```
31
+
32
+ ## Suggested order
33
+
34
+ 1. **`basic_server.rb`** — defines and provisions the `Artist`, `Song`, and
35
+ `Post` classes the other scripts use. Run it first.
36
+ 2. **`basic_client.rb`** — see how the same SDK behaves without the master key,
37
+ and watch Parse Server enforce a row-level ACL.
38
+ 3. **`live_query_listener.rb`** — leave it running, then create/update/destroy
39
+ `Post`s from another terminal (or the dashboard) and watch them stream in.
40
+ 4. **`rag_chatbot.rb`** — requires an Atlas-backed server and an embedding key;
41
+ see [`../docs/atlas_vector_search_guide.md`](../docs/atlas_vector_search_guide.md)
42
+ for the vector-search setup.
43
+
44
+ > Each script's header comment lists the exact environment variables and any
45
+ > prerequisites (e.g. `basic_client.rb` needs the `Post` class to already
46
+ > exist, which `basic_server.rb` provisions).
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Basic Client Setup for parse-stack-next
5
+ #
6
+ # The UNPRIVILEGED side: configure the SDK WITHOUT a master key — the way a
7
+ # mobile app, browser, or untrusted worker uses it. There is no admin escape
8
+ # hatch, so authorization is carried per-call by the user's sessionToken and
9
+ # Parse Server is the enforcement boundary (CLP rejects, ACL filters rows,
10
+ # protectedFields strips columns).
11
+ #
12
+ # This example logs a user in and shows that a row-level ACL actually blocks
13
+ # reads: the owning user can read their object; an anonymous client cannot.
14
+ #
15
+ # See basic_server.rb for the privileged (master-key) counterpart.
16
+ #
17
+ # Prerequisite: the `Post` class must already exist on the server. A no-master
18
+ # client cannot create a class when Parse Server's allowClientClassCreation is
19
+ # false (the default since 5.0), so run examples/basic_server.rb first (it
20
+ # provisions Post with the master key) — or create the class yourself.
21
+ #
22
+ # Run it (REST key only — no master key in this process):
23
+ # export PARSE_SERVER_URL=http://localhost:1337/parse
24
+ # export PARSE_APP_ID=... PARSE_REST_KEY=...
25
+ # ruby examples/basic_client.rb
26
+
27
+ require "parse-stack-next"
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # 1. Configure a no-master-key client
31
+ # ---------------------------------------------------------------------------
32
+ Parse.setup(
33
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
34
+ app_id: ENV.fetch("PARSE_APP_ID"),
35
+ api_key: ENV.fetch("PARSE_REST_KEY"),
36
+ master_key: nil, # explicit: never set this from env in client builds
37
+ logging: false,
38
+ )
39
+
40
+ # Belt-and-suspenders: prove the master key really is absent.
41
+ raise "master key leaked into a client process!" unless Parse.client.master_key.nil?
42
+
43
+ class Post < Parse::Object
44
+ property :title, :string
45
+ property :body, :string
46
+ end
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # 2. Authenticate (log in, or sign up on first run)
50
+ # ---------------------------------------------------------------------------
51
+ USERNAME = "ada"
52
+ PASSWORD = "p4ssw0rd!"
53
+
54
+ # Parse::User.login returns nil on bad/unknown credentials (it does not raise),
55
+ # so fall back to signup the first time.
56
+ user = Parse::User.login(USERNAME, PASSWORD) ||
57
+ Parse::User.signup(USERNAME, PASSWORD, "ada@example.com")
58
+
59
+ puts "Logged in as #{user.username} (#{user.id})"
60
+ puts "Session token: #{user.session_token[0, 8]}…"
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # 3. Create an owner-only object AS the user
64
+ # ---------------------------------------------------------------------------
65
+ # `with_session` authorizes every REST-routed op in the block as this user.
66
+ post = user.with_session do
67
+ p = Post.new(title: "My private note", body: "Only Ada may read this.")
68
+ # Owner-only ACL: grant read+write to this user, no public access.
69
+ acl = Parse::ACL.new # empty == no public, no one
70
+ acl.apply(user.id, true, true) # this user: read + write
71
+ p.acl = acl
72
+ p.save
73
+ p
74
+ end
75
+ puts "Created Post #{post.id} with an owner-only ACL"
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # 4. Read it back AS the owner — succeeds
79
+ # ---------------------------------------------------------------------------
80
+ as_owner = user.with_session { Post.find(post.id) }
81
+ puts "As owner -> #{as_owner ? "READ OK: #{as_owner.title.inspect}" : "BLOCKED"}"
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # 5. Read it back ANONYMOUSLY (no session token) — blocked by the ACL
85
+ # ---------------------------------------------------------------------------
86
+ # No master key + no session => a plain REST request the ACL filters out.
87
+ # `first` returns nil rather than raising when the row is not visible.
88
+ anon = Post.first(objectId: post.id)
89
+ puts "Anonymous -> #{anon ? "READ OK (unexpected!): #{anon.title.inspect}" : "BLOCKED (nil) — ACL enforced"}"
90
+
91
+ # Takeaway: identical SDK calls return the row for the owner and nil for an
92
+ # unauthorized caller. That difference is Parse Server enforcing the ACL —
93
+ # the client SDK simply threads the auth context and reports the verdict.
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Basic Server-Side Setup for parse-stack-next
5
+ #
6
+ # The privileged way an app/server boots the SDK: configure a client WITH the
7
+ # master key, define a model, push its schema, and do CRUD + queries. Because
8
+ # the master key is present, Parse Server treats every request as an admin
9
+ # operation (ACL / CLP / protectedFields are bypassed) — which is exactly what
10
+ # you want for a trusted backend, and exactly what you must NOT do in an
11
+ # untrusted client (see basic_client.rb for that side).
12
+ #
13
+ # Run it:
14
+ # export PARSE_SERVER_URL=http://localhost:1337/parse
15
+ # export PARSE_APP_ID=... PARSE_REST_KEY=... PARSE_MASTER_KEY=...
16
+ # ruby examples/basic_server.rb
17
+
18
+ require "parse-stack-next"
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # 1. Configure the (master-key) client
22
+ # ---------------------------------------------------------------------------
23
+ Parse.setup(
24
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
25
+ app_id: ENV.fetch("PARSE_APP_ID"),
26
+ api_key: ENV.fetch("PARSE_REST_KEY"),
27
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
28
+ )
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # 2. Define models
32
+ # ---------------------------------------------------------------------------
33
+ class Artist < Parse::Object
34
+ property :name, :string, required: true
35
+ property :country, :string
36
+ end
37
+
38
+ class Song < Parse::Object
39
+ property :title, :string, required: true
40
+ property :plays, :integer, default: 0
41
+ property :released_on, :date
42
+
43
+ belongs_to :artist # stored as a Pointer<Artist>
44
+ end
45
+
46
+ # Provisioned here for the companion basic_client.rb. A no-master client can't
47
+ # create a class when Parse Server's allowClientClassCreation is false (the
48
+ # default since Parse Server 5.0), so the trusted side defines it up front.
49
+ class Post < Parse::Object
50
+ property :title, :string
51
+ property :body, :string
52
+ end
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # 3. Push the schema (server-side only — needs the master key)
56
+ # ---------------------------------------------------------------------------
57
+ # auto_upgrade! creates the class and any missing columns on Parse Server to
58
+ # match the model definition. Run it at boot / deploy, not on every request.
59
+ Artist.auto_upgrade!
60
+ Song.auto_upgrade!
61
+ Post.auto_upgrade!
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # 4. Create
65
+ # ---------------------------------------------------------------------------
66
+ artist = Artist.create!(name: "Daft Punk", country: "FR")
67
+
68
+ song = Song.new(title: "One More Time", plays: 1_000, artist: artist)
69
+ song.save # => true (returns false + sets .errors on failure)
70
+ puts "Created Song #{song.id}: #{song.title}"
71
+
72
+ # create! is `new(attrs).save!` in one call (raises on failure):
73
+ Song.create!(title: "Harder, Better, Faster, Stronger", plays: 2_500, artist: artist)
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # 5. Read
77
+ # ---------------------------------------------------------------------------
78
+ found = Song.query(:objectId => song.id).include(:artist).first # eager-load the pointer
79
+ puts "Fetched: #{found.title} by #{found.artist.name}"
80
+
81
+ first_hit = Song.first(title: "One More Time")
82
+ puts "First match plays: #{first_hit.plays}"
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # 6. Update
86
+ # ---------------------------------------------------------------------------
87
+ song.plays += 1
88
+ song.save
89
+ puts "Updated plays: #{song.plays}"
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # 7. Query
93
+ # ---------------------------------------------------------------------------
94
+ # DataMapper-style constraints. Symbol operators (:plays.gt) build comparisons;
95
+ # order / limit chain on.
96
+ popular = Song.query(:plays.gt => 1_500)
97
+ .where(artist: artist)
98
+ .order(:plays.desc)
99
+ .limit(10)
100
+ .results
101
+ puts "Popular songs: #{popular.map(&:title).join(', ')}"
102
+
103
+ puts "Total songs by #{artist.name}: #{Song.count(artist: artist)}"
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # 8. Delete
107
+ # ---------------------------------------------------------------------------
108
+ song.destroy
109
+ puts "Destroyed #{song.id}"