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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/CHANGELOG.md +461 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +160 -3
- data/Rakefile +52 -3
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +5 -0
- data/docs/mcp_guide.md +59 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +55 -2
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +180 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/querying.rb +3 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +17 -0
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +252 -7
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- 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.
|
data/examples/README.md
ADDED
|
@@ -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}"
|