parse-stack-next 5.8.0 → 5.8.2
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/CHANGELOG.md +630 -0
- data/README.md +31 -19
- data/docs/caching.md +90 -5
- data/docs/mcp_guide.md +22 -5
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +94 -9
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +521 -70
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +50 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +263 -11
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +173 -33
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +142 -11
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +307 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +41 -5
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +43 -2
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +665 -41
- data/lib/parse/query.rb +802 -60
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -0
- data/lib/parse/vector_search/hybrid.rb +78 -16
- data/lib/parse/vector_search.rb +215 -7
- data/lib/parse/webhooks/payload.rb +82 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +329 -13
- metadata +1 -1
data/README.md
CHANGED
|
@@ -3069,8 +3069,13 @@ songs.save
|
|
|
3069
3069
|
|
|
3070
3070
|
# you can also destroy a set of objects
|
|
3071
3071
|
songs.destroy
|
|
3072
|
+
|
|
3073
|
+
# send every write as a specific user (ACL and CLP enforced)
|
|
3074
|
+
songs.save(session: user)
|
|
3072
3075
|
```
|
|
3073
3076
|
|
|
3077
|
+
Each write in a batch keeps the credentials it was built for. Parse Server runs a `POST /batch` under one credential, so objects whose classes use clients with different credentials (for example a class bound to `Parse.client.become(token)`; identically configured clients are merged), or raw `Parse::Request`s carrying different `session_token:` or `use_master_key:` options, are sent as separate batch calls, each with its own credentials. Responses still come back in request order.
|
|
3078
|
+
|
|
3074
3079
|
### Magic `save_all`
|
|
3075
3080
|
By default, all Parse queries have a maximum fetch limit of 1000. While using the `:max` option, Parse-Stack can increase this up to 11,000. In the cases where you need to update a large number of objects, you can utilize the `Parse::Object#save_all` method to fetch, modify and save objects.
|
|
3076
3081
|
|
|
@@ -3131,6 +3136,7 @@ end
|
|
|
3131
3136
|
- **Mixed operations**: Support create, update, and delete operations in single transaction
|
|
3132
3137
|
- **Error handling**: Comprehensive error handling with meaningful exception messages
|
|
3133
3138
|
- **Object ID assignment**: New objects automatically receive their `objectId`, `createdAt`, and `updatedAt` from the server response after successful transaction
|
|
3139
|
+
- **One credential**: A transaction runs with the credentials of its objects' class client, or as a given user with `Parse::Object.transaction(session: user)`. Objects bound to clients with different credentials raise `Parse::BatchOperation::MixedAuthorityError` before anything is sent, because a transaction cannot be split.
|
|
3134
3140
|
|
|
3135
3141
|
```ruby
|
|
3136
3142
|
# Transaction with custom retry limit and error handling
|
|
@@ -3873,8 +3879,8 @@ If you want to see if a particular field contains a specific Parse::Object (poin
|
|
|
3873
3879
|
q.where :field => Parse::Pointer.new("_User", "anObjectId")
|
|
3874
3880
|
# alias using subclass helper
|
|
3875
3881
|
q.where :field => Parse::User.pointer("anObjectId")
|
|
3876
|
-
# alias using
|
|
3877
|
-
q.where :user.
|
|
3882
|
+
# alias using the `pointer_id` constraint. We will infer :user maps to class "_User" (Parse::User)
|
|
3883
|
+
q.where :user.pointer_id => "anObjectId"
|
|
3878
3884
|
```
|
|
3879
3885
|
|
|
3880
3886
|
#### Less Than
|
|
@@ -3976,15 +3982,15 @@ Match arrays by their length:
|
|
|
3976
3982
|
|
|
3977
3983
|
```ruby
|
|
3978
3984
|
# Exact size
|
|
3979
|
-
q.where :tags.
|
|
3985
|
+
q.where :tags.array_size => 2 # arrays with exactly 2 elements
|
|
3980
3986
|
|
|
3981
3987
|
# Size comparisons
|
|
3982
|
-
q.where :tags.
|
|
3983
|
-
q.where :tags.
|
|
3984
|
-
q.where :tags.
|
|
3985
|
-
q.where :tags.
|
|
3986
|
-
q.where :tags.
|
|
3987
|
-
q.where :tags.
|
|
3988
|
+
q.where :tags.array_size => { gt: 3 } # size > 3
|
|
3989
|
+
q.where :tags.array_size => { gte: 2 } # size >= 2
|
|
3990
|
+
q.where :tags.array_size => { lt: 5 } # size < 5
|
|
3991
|
+
q.where :tags.array_size => { lte: 4 } # size <= 4
|
|
3992
|
+
q.where :tags.array_size => { ne: 0 } # size != 0
|
|
3993
|
+
q.where :tags.array_size => { gte: 2, lt: 10 } # range: 2 <= size < 10
|
|
3988
3994
|
|
|
3989
3995
|
# Empty/non-empty shortcuts (index-friendly)
|
|
3990
3996
|
q.where :tags.arr_empty => true # empty arrays (uses { field: [] })
|
|
@@ -4033,7 +4039,7 @@ All array constraints work with `has_many :through => :array` relations:
|
|
|
4033
4039
|
Product.query(:categories.set_equals => [cat1, cat2])
|
|
4034
4040
|
|
|
4035
4041
|
# Find products with more than 3 categories
|
|
4036
|
-
Product.query(:categories.
|
|
4042
|
+
Product.query(:categories.array_size => { gt: 3 })
|
|
4037
4043
|
```
|
|
4038
4044
|
|
|
4039
4045
|
**Note:** Array constraints using aggregation pipelines require MongoDB 3.6+.
|
|
@@ -4213,7 +4219,7 @@ Event.where(:updated_at.between_dates => [1.week.ago, Time.now])
|
|
|
4213
4219
|
```
|
|
4214
4220
|
|
|
4215
4221
|
#### Matches Object Id
|
|
4216
|
-
Sometimes you want to find rows where a particular Parse object exists. You can do so by passing a the Parse::Object subclass or a Parse::Pointer. In some cases you may only have the "objectId" of the record you are looking for. For convenience, you can also use the `
|
|
4222
|
+
Sometimes you want to find rows where a particular Parse object exists. You can do so by passing a the Parse::Object subclass or a Parse::Pointer. In some cases you may only have the "objectId" of the record you are looking for. For convenience, you can also use the `pointer_id` constraint. This will assume that the name of the field matches a particular Parse class you have defined. Assume the following:
|
|
4217
4223
|
|
|
4218
4224
|
```ruby
|
|
4219
4225
|
# where this Parse object equals the object in the column `field`.
|
|
@@ -4221,18 +4227,18 @@ q.where :field => Parse::Pointer("Field", "someObjectId")
|
|
|
4221
4227
|
# => "field":{"__type":"Pointer","className":"Field","objectId":"someObjectId"}}
|
|
4222
4228
|
|
|
4223
4229
|
# alias, shorthand when we infer `:field` maps to `Field` parse class.
|
|
4224
|
-
q.where :field.
|
|
4230
|
+
q.where :field.pointer_id => "someObjectId"
|
|
4225
4231
|
# => "field":{"__type":"Pointer","className":"Field","objectId":"someObjectId"}}
|
|
4226
4232
|
|
|
4227
4233
|
```
|
|
4228
4234
|
It is always important to be thoughtful in naming column names in associations as
|
|
4229
4235
|
close to their foreign Parse class names. This enables more expressive syntax while reducing
|
|
4230
|
-
code.
|
|
4236
|
+
code. `pointer_id` also accepts any object or pointer object. These are all equivalent:
|
|
4231
4237
|
|
|
4232
4238
|
```ruby
|
|
4233
4239
|
q.where :user => User.pointer("xyx123")
|
|
4234
|
-
q.where :user.
|
|
4235
|
-
q.where :user.
|
|
4240
|
+
q.where :user.pointer_id => "xyx123"
|
|
4241
|
+
q.where :user.pointer_id => User.pointer("xyx123")
|
|
4236
4242
|
# All produce
|
|
4237
4243
|
# => "user":{"__type":"Pointer","className":"_User","objectId":"xyx123"}}
|
|
4238
4244
|
```
|
|
@@ -4260,15 +4266,15 @@ In some cases, you do not have the Parse object, but you have its `objectId`. Yo
|
|
|
4260
4266
|
|
|
4261
4267
|
```ruby
|
|
4262
4268
|
# shorthand if you are using convention. Will infer class `Artist`
|
|
4263
|
-
Song.all :artist.
|
|
4269
|
+
Song.all :artist.pointer_id => artist_id
|
|
4264
4270
|
|
|
4265
4271
|
# other approaches, same result
|
|
4266
4272
|
Song.all :artist => Artist.pointer(artist_id)
|
|
4267
4273
|
Song.all :artist => Parse::Pointer.new("Artist", artist_id)
|
|
4268
4274
|
|
|
4269
|
-
#
|
|
4275
|
+
# `pointer_id` safely accepts pointers and strings for these kinds of API patterns
|
|
4270
4276
|
def find_songs(artist)
|
|
4271
|
-
Song.all :artist.
|
|
4277
|
+
Song.all :artist.pointer_id => artist
|
|
4272
4278
|
end
|
|
4273
4279
|
|
|
4274
4280
|
# all ok
|
|
@@ -5813,7 +5819,7 @@ Event.query(:event_date.gt => Time.now).results_direct
|
|
|
5813
5819
|
Event.query(:event_date.gte => start_date, :event_date.lte => end_date).results_direct
|
|
5814
5820
|
|
|
5815
5821
|
# Array operators
|
|
5816
|
-
Song.query(:tags.
|
|
5822
|
+
Song.query(:tags.array_size => 3).results_direct
|
|
5817
5823
|
Song.query(:tags.contains_all => ["rock", "classic"]).results_direct
|
|
5818
5824
|
Song.query(:tags.empty_or_nil => true).results_direct
|
|
5819
5825
|
|
|
@@ -6101,6 +6107,12 @@ Parse.client.authorization.invalidate(token)
|
|
|
6101
6107
|
Parse.client.authorization.invalidate_user_roles(user_id)
|
|
6102
6108
|
```
|
|
6103
6109
|
|
|
6110
|
+
A custom identity plane that implements neither `invalidate_value` nor
|
|
6111
|
+
generation support (`generation`, `generation_current?`, `bump_generation`)
|
|
6112
|
+
can only forget one token at a time. Revoking every session
|
|
6113
|
+
of a user (password change, account deletion, `logout_all!`, a batch destroy of
|
|
6114
|
+
sessions) is then bounded by `identity_cache_ttl` rather than immediate.
|
|
6115
|
+
|
|
6104
6116
|
Both caches default to per-process memory. With a keyspaced
|
|
6105
6117
|
`Parse::Cache::Redis` you can move them to the shared backend so every worker
|
|
6106
6118
|
resolves against the same view, and let webhook triggers invalidate them
|
data/docs/caching.md
CHANGED
|
@@ -230,6 +230,38 @@ caching path the two never meet. Three points are worth knowing anyway.
|
|
|
230
230
|
|
|
231
231
|
## Auth scoping of cached responses
|
|
232
232
|
|
|
233
|
+
### Session reads are not cached by default
|
|
234
|
+
|
|
235
|
+
A read made with a session token is never read from or stored in the response
|
|
236
|
+
cache unless the application opts in:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
Parse::Middleware::Caching.cache_session_requests = true # every client
|
|
240
|
+
Parse.setup(cache_session_requests: true, ...) # one client
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Only `true` enables it (a `"true"` String from an environment variable does
|
|
244
|
+
not). A client's own `cache_session_requests:` option overrides the class
|
|
245
|
+
default in either direction.
|
|
246
|
+
|
|
247
|
+
A cached answer is served without contacting Parse Server, so it cannot notice
|
|
248
|
+
that the session was revoked (logout, `Parse::Session#destroy`, `logout_all!`,
|
|
249
|
+
a password change) or that the user lost a role or row access through a change
|
|
250
|
+
this process did not make. With the default, every REST session read reaches
|
|
251
|
+
Parse Server, which checks the token and the current ACLs and CLPs each time.
|
|
252
|
+
Mongo-direct reads, including agent tools that route there, do not go through
|
|
253
|
+
this cache; they resolve the session through the identity plane, whose
|
|
254
|
+
revocation behavior is described in
|
|
255
|
+
[Identity and role caching](#identity-and-role-caching). This
|
|
256
|
+
applies however the session reached the request: an explicit `session_token:`,
|
|
257
|
+
`Parse.with_session`, or a client bound to a session. Master-key and
|
|
258
|
+
anonymous reads cache as before, and writes made with a session still
|
|
259
|
+
invalidate cached entries. Turning the option on trades that guarantee for
|
|
260
|
+
speed: a revoked or narrowed session keeps reading its cached responses until
|
|
261
|
+
they expire, so keep `expires:` short if you enable it.
|
|
262
|
+
|
|
263
|
+
### The auth discriminator
|
|
264
|
+
|
|
233
265
|
The cache key carries an auth discriminator, and this is a correctness property
|
|
234
266
|
rather than an optimization.
|
|
235
267
|
|
|
@@ -251,9 +283,9 @@ URL digest is placed before the discriminator, so every auth variant of one
|
|
|
251
283
|
resource shares a prefix, which is exactly what lets a write invalidate the
|
|
252
284
|
resource for all callers with a single pattern delete.
|
|
253
285
|
|
|
254
|
-
One consequence worth planning for
|
|
255
|
-
cache entry per session per URL. Cardinality
|
|
256
|
-
with distinct resources.
|
|
286
|
+
One consequence worth planning for, once session caching is enabled: a heavily
|
|
287
|
+
multi-user endpoint produces one cache entry per session per URL. Cardinality
|
|
288
|
+
scales with active sessions, not with distinct resources.
|
|
257
289
|
|
|
258
290
|
## Identity and role caching
|
|
259
291
|
|
|
@@ -358,6 +390,56 @@ Two behaviors to know before you rely on these:
|
|
|
358
390
|
triggers depend on. The deprecated `Parse::AtlasSearch::Session.invalidate` /
|
|
359
391
|
`.invalidate_user_roles` forms still work through 5.x: they delegate to the
|
|
360
392
|
default client's context, so they can only ever address `Parse.client`.
|
|
393
|
+
* A custom identity plane you write yourself needs `get`, `set`, and
|
|
394
|
+
`invalidate`. Revoking one token (logout, `Parse::Session#destroy` on a
|
|
395
|
+
session that carries its token) works with those alone. Revoking every token
|
|
396
|
+
of a user (password change, account deletion, `logout_all!`, destroying a
|
|
397
|
+
session fetched without its token, or a batch `Array#destroy` of sessions or
|
|
398
|
+
users) also needs either `invalidate_value(user_id)` or generation support
|
|
399
|
+
(`generation`, `generation_current?`, and `bump_generation`), because the
|
|
400
|
+
plane is keyed by token. A plane with neither keeps resolving
|
|
401
|
+
those tokens until their cached entries expire, so revocation there is
|
|
402
|
+
bounded by `identity_cache_ttl`, not immediate. Lower the TTL if that window
|
|
403
|
+
is too long.
|
|
404
|
+
* A session or user delete drops its cached identity when the delete succeeds
|
|
405
|
+
or reports the row already gone ("object not found"); a denied delete leaves
|
|
406
|
+
the cache alone. A session fetched without its token is looked up first
|
|
407
|
+
(with the master key when the client has one, otherwise with the delete's
|
|
408
|
+
`session:`). When the lookup finds no row (the session is already gone or
|
|
409
|
+
not visible), the delete uses the owner recorded when a `_Session` query or
|
|
410
|
+
`Parse::Session.session` last fetched that session through the same client.
|
|
411
|
+
Only the owner's user id is recorded, never the token, for as long as an
|
|
412
|
+
identity entry lives. The records sit in their own store
|
|
413
|
+
(`client.authorization.session_owner_cache`, process-local by default, set
|
|
414
|
+
a shared store to share them), never in the identity plane, so no session
|
|
415
|
+
token can read one. With no
|
|
416
|
+
recorded owner, a batch delete that reports the row deleted or "object not
|
|
417
|
+
found" clears the client's identity and role cache, and so does a lookup
|
|
418
|
+
that fails outright. Either reset happens at most once every 5 seconds per
|
|
419
|
+
client, so an endpoint that deletes caller-supplied session ids cannot flush
|
|
420
|
+
a shared plane on every request. A single `destroy` cannot tell "already
|
|
421
|
+
gone" from "denied", so it uses the recorded owner but never resets. The
|
|
422
|
+
residual window is a session deleted elsewhere whose owner this process
|
|
423
|
+
never recorded: its cached token resolves until the next reset or until
|
|
424
|
+
`identity_cache_ttl`.
|
|
425
|
+
* A token resolution that is in flight when an invalidation lands does not
|
|
426
|
+
cache its answer, so it cannot put a just-revoked token back. Every
|
|
427
|
+
invalidation moves a marker before it drops entries, and the resolver
|
|
428
|
+
checks the marker before writing and again after, evicting its own write
|
|
429
|
+
when it moved. On the Redis identity plane the marker is a random nonce
|
|
430
|
+
shared by every process, replaced on each invalidation, so this also holds
|
|
431
|
+
for invalidations made by other processes, including a full reset. A custom
|
|
432
|
+
plane gets the same cross-process guarantee by implementing
|
|
433
|
+
`invalidation_nonce` (return the current marker String, or nil when none is
|
|
434
|
+
set) and `bump_invalidation_nonce` (store and return a new random String).
|
|
435
|
+
A custom plane with generation support but no nonce shares a generation
|
|
436
|
+
counter instead. That covers single-token and per-user invalidations made
|
|
437
|
+
by other processes, but not a full reset if the plane's `clear` also deletes
|
|
438
|
+
its generation counters: the counter restarts at a value an in-flight
|
|
439
|
+
lookup may already hold, so a lookup racing another process's reset can
|
|
440
|
+
keep its answer until `identity_cache_ttl`. A plane with neither gets the
|
|
441
|
+
guarantee within one process only, and across processes it is bounded by
|
|
442
|
+
`identity_cache_ttl`.
|
|
361
443
|
|
|
362
444
|
On a Redis outage these planes behave differently from the response cache.
|
|
363
445
|
The response cache degrades to a passthrough request; the identity and role
|
|
@@ -653,8 +735,10 @@ Work down this list.
|
|
|
653
735
|
so deliberately.
|
|
654
736
|
5. Is this a `fetch!` or `reload!`? Those default to write-only mode, which by
|
|
655
737
|
design never reads from the cache. Use `fetch_cache!` to accept a cached body.
|
|
656
|
-
6. Is the
|
|
657
|
-
|
|
738
|
+
6. Is the read made with a session token? Session reads bypass the cache
|
|
739
|
+
unless `Parse::Middleware::Caching.cache_session_requests = true`. With it
|
|
740
|
+
on, entries are not shared across auth identities, so the first request for
|
|
741
|
+
each session is always a miss.
|
|
658
742
|
7. Is the store process-local? A Moneta memory store behind several workers hits
|
|
659
743
|
only when the same worker handles the repeat.
|
|
660
744
|
8. Has something set `Parse::Middleware::Caching.enabled = false`? That is the
|
|
@@ -675,6 +759,7 @@ All events are `ActiveSupport::Notifications`.
|
|
|
675
759
|
| `parse.cache.store` | the same, plus `duration_ms` |
|
|
676
760
|
| `parse.cache.delete` | the same, emitted on an invalidating write |
|
|
677
761
|
| `parse.cache.error` | the same, plus `error` (the exception class name only) |
|
|
762
|
+
| `parse.cache.bypass` | the same, plus `reason: :session` for a session read kept out of the cache |
|
|
678
763
|
| `parse.cache.evict` | `pattern_digest`, `deleted`, `duration_ms` |
|
|
679
764
|
| `parse.synchronize_create.acquired` | `key_digest`, `wait_ms` |
|
|
680
765
|
| `parse.synchronize_create.contended` | `key_digest`, `elapsed_ms` |
|
data/docs/mcp_guide.md
CHANGED
|
@@ -880,6 +880,15 @@ The approval gate is a pluggable `agent.approval_gate` consulted inside
|
|
|
880
880
|
unit-testable with a fake approver. `Parse::Agent::MCPElicitationGate` is the
|
|
881
881
|
spec-native implementation; `Parse::Agent::NullGate` (the default) approves.
|
|
882
882
|
|
|
883
|
+
A sub-agent built with `parent:` and no gate of its own uses its parent's gate,
|
|
884
|
+
read at call time, so the gate the dispatcher installs on the parent for a
|
|
885
|
+
`tools/call` also covers any sub-agent a tool builds during that call. Before
|
|
886
|
+
5.8.1 a sub-agent fell back to `NullGate` and skipped approval. Assigning a
|
|
887
|
+
gate on the sub-agent itself overrides this, and assigning `nil` removes the
|
|
888
|
+
override so the sub-agent uses its parent's gate again. The dispatcher
|
|
889
|
+
snapshots and restores the agent's own gate (`approval_gate_override`), so
|
|
890
|
+
driving a sub-agent through `tools/call` never pins its parent's gate onto it.
|
|
891
|
+
|
|
883
892
|
Round-trip over the streaming transport:
|
|
884
893
|
|
|
885
894
|
1. A `tools/call` for a gated tier pauses before execution. The server builds an
|
|
@@ -1646,7 +1655,7 @@ agent = Parse::Agent.new(methods: [:archive, "Project.set_client_description"])
|
|
|
1646
1655
|
agent = Parse::Agent.new(methods: { except: ["Account.delete_account"] })
|
|
1647
1656
|
```
|
|
1648
1657
|
|
|
1649
|
-
Entries are bare method names (`:archive
|
|
1658
|
+
Entries are bare method names (`:archive`, which matches the method on any class) or qualified names (`"Project.archive"`, which matches only on that class). Both forms coexist in the same Set; matching is an OR. The class part is compared by its Parse class name, so `"User.reset"` and `"_User.reset"` name the same method (likewise `Role`, `Session`, and `Installation`), whichever spelling the entry or the `call_method` caller uses. An entry may also name the model by its Ruby constant (`"Artist.purge"` for `class Artist < Parse::Object; parse_class "Musician"; end`), which is compared by its Parse class name too. A qualified entry whose class resolves to no loaded model warns at construction and is matched by its class part as written. Before 5.8.1 an entry was matched against the raw string the caller passed, so `methods: { except: ["_User.reset"] }` could be bypassed by calling it on `"User"`.
|
|
1650
1659
|
|
|
1651
1660
|
The filter **narrows declared methods** — it cannot expose a method that was not declared via the `agent_method` DSL, and it cannot bypass tier checks (`agent_can_call?`) or env-gates (`PARSE_AGENT_ALLOW_WRITE_TOOLS`, `PARSE_AGENT_ALLOW_SCHEMA_OPS`). A filtered-out invocation returns `error_code: :tool_filtered`.
|
|
1652
1661
|
|
|
@@ -1663,7 +1672,12 @@ class Project < Parse::Object
|
|
|
1663
1672
|
# Forward the agent's scope to any internal query — pre-filtering by
|
|
1664
1673
|
# _wperm so the update only sees rows the agent's scope is allowed
|
|
1665
1674
|
# to modify, defense-in-depth alongside Parse Server's own ACL.
|
|
1666
|
-
|
|
1675
|
+
# acl_scope? is true for any scoped agent, including a session agent whose
|
|
1676
|
+
# token has not resolved yet (acl_scope itself would raise
|
|
1677
|
+
# Parse::Agent::UnresolvedIdentity for that one); acl_scope_kwargs
|
|
1678
|
+
# forwards the identity, and the scoped read fails closed if it cannot
|
|
1679
|
+
# be resolved.
|
|
1680
|
+
Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope?
|
|
1667
1681
|
self.archived = true # property :archived, :boolean
|
|
1668
1682
|
self.archive_reason = reason # property :archive_reason, :string
|
|
1669
1683
|
save
|
|
@@ -1714,7 +1728,7 @@ Parse::Agent.new(classes: { only: [Pots] }, strict_class_filter: true) # per-in
|
|
|
1714
1728
|
|
|
1715
1729
|
`except:` is never validated — an operator may proactively block a class not yet loaded.
|
|
1716
1730
|
|
|
1717
|
-
**Sub-agent inheritance: intersect, never widen.**
|
|
1731
|
+
**Sub-agent inheritance: intersect, never widen.** Like `tools:` and `methods:`, `classes:` is **intersected** with the parent's effective set so a sub-agent can NEVER widen the parent's data reach. A child `only:` that has no overlap with the parent's `only:` raises `ArgumentError` at construction. A child that omits `classes:` inherits the parent's filter verbatim. `except:` sets are unioned (a sub-agent cannot un-deny a class the parent denied).
|
|
1718
1732
|
|
|
1719
1733
|
**Schema-catalog filtering.** `get_all_schemas` omits classes outside the per-agent allowlist from the catalog response so the LLM doesn't waste a tool call discovering classes it would be refused on.
|
|
1720
1734
|
|
|
@@ -1793,6 +1807,7 @@ Parse::Agent::Tools.register(
|
|
|
1793
1807
|
| `session_token` | Yes (security-critical) | Without it, a session-token parent silently produces a master-key sub-agent — the constructor default is `nil`, which means master-key mode. This was the v4.2 advisor-flagged blocker; do not undo. |
|
|
1794
1808
|
| `acl_user` (v4.4.0) | Yes (security-critical) | When the parent was constructed with `acl_user:` and the child supplies none of `session_token:` / `acl_user:` / `acl_role:`, the parent's identity inherits verbatim. Inheritance is conditional on the child supplying NO identity at all — explicit overrides on the child resolve normally and then face the subset check below. |
|
|
1795
1809
|
| `acl_role` (v4.4.0) | Yes (security-critical) | Same rule as `acl_user`. A child that omits identity inherits the parent's role scope; one that supplies its own identity falls through to the subset check. |
|
|
1810
|
+
| `master_atlas` | Yes (security-critical) | `nil` inherits the parent's setting and `false` drops it. `true` is accepted only when the parent has it: since 5.8.1 a sub-agent of a parent without `master_atlas` raises `ArgumentError`, because master-key faceting would return every row's buckets and results under the parent's scoped identity. |
|
|
1796
1811
|
| `tenant_id` | Yes (security-critical) | Without it, a tenant-bound parent produces an unbound sub-agent that escapes `agent_tenant_scope` rules. |
|
|
1797
1812
|
| `recursion_depth` | Always (decremented) | The parent's budget is authoritative — the explicit `recursion_depth:` kwarg is ignored on inherited construction. |
|
|
1798
1813
|
|
|
@@ -1802,7 +1817,8 @@ Parse::Agent::Tools.register(
|
|
|
1802
1817
|
|-------|---------|
|
|
1803
1818
|
| `permissions` | The default of `:readonly` means `Parse::Agent.new(parent: write_agent)` produces a `:readonly` sub-agent. A sub-agent is at most as privileged as the parent by tier; this is enforced by a clamp check at construction, not by inheritance. An explicit override is accepted only if `≤ parent.permissions` — `Parse::Agent.new(parent: readonly_parent, permissions: :admin)` raises `ArgumentError`. Pass `permissions: parent.permissions` to maintain parity intentionally. |
|
|
1804
1819
|
| `client` | The constructor default `:default` resolves to the same client in standard single-app deployments. Explicit passes through. |
|
|
1805
|
-
|
|
1820
|
+
|
|
1821
|
+
**`tools:` and `methods:` narrow only (5.8.1).** A sub-agent's `tools:` and `methods:` filters can only remove from what its parent allows. A child `tools: { only: }` is intersected with the parent's allowlist and `except:` sets are unioned; a child that omits `tools:` inherits the parent's filter. For `methods:`, the parent's filters stay in force alongside the child's, so a call must pass every one (entries may be bare or `Class.method`, which plain set intersection would match wrongly). A child `only:` list with nothing its parent allows (after the parent's allowlist and `except:` lists apply) raises `ArgumentError` at construction; an explicitly empty `only: []` is the strictest narrowing and is accepted. Before 5.8.1 a child's filter replaced the parent's, so a sub-agent could enable a tool or agent method within its tier that the parent was not given.
|
|
1806
1822
|
|
|
1807
1823
|
**The clamp invariant:** `sub.permissions ≤ parent.permissions` always holds. The default `:readonly` is always safe regardless of parent tier; only explicit overrides hit the clamp check, and overrides that exceed the parent's tier raise at construction. This is the structural guarantee that a `delegate_to_subagent` chain cannot escape the parent's tier through sub-agent construction — the only path to a more-privileged agent is at the MCP factory, where the explicit elevation is auditable.
|
|
1808
1824
|
|
|
@@ -2785,6 +2801,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
|
|
|
2785
2801
|
| `:tools_except` | Array<Symbol> | v4.3.0+ — when the agent was constructed with `tools: { except: [...] }`. |
|
|
2786
2802
|
| `:methods_only` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { only: [...] }`. Bare names and `"Class.method"` qualified names mix. |
|
|
2787
2803
|
| `:methods_except` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { except: [...] }`. |
|
|
2804
|
+
| `:methods_layers` | Array<Hash> | 5.8.1+, sub-agents that inherit at least one `methods:` filter (including a single parent layer when the sub-agent has none of its own). Every `methods:` filter in force, parent layers first and the agent's own last, each `{only:, except:}` with sorted name strings (nil when absent). `:methods_only` / `:methods_except` describe only the agent's own layer. `describe[:methods][:layers]` carries the same list. |
|
|
2788
2805
|
| `:filters` | Hash<String,Array<String>> | v4.4.0+ — when the agent was constructed with `filters: {...}`. Maps each filtered class name (or `"default"`) to the list of FIELD NAMES the filter constrains. Filter VALUES are intentionally NOT echoed — `filters: { Account => { user_id: "abc123" } }` would otherwise emit the user-identifying value on every audit-log line. Subscribers that need the actual constraint can call `agent.filter_for(class_name)` directly. |
|
|
2789
2806
|
| `:denial_kind` | Symbol | v4.3.0+, AccessDenied failure path only — one of `:hidden_class` (global `agent_hidden`), `:class_filter` (per-agent `classes:` narrowing), `:field_denied` (outside `agent_fields`), or `:storage_form_field_ref` (referenced `_p_*` pointer-storage column). Lets SOC tooling distinguish operator narrowing from policy-level denials without parsing the message prose. |
|
|
2790
2807
|
|
|
@@ -3427,7 +3444,7 @@ Parse::Agent.rack_app(logger: Rails.logger) { |env| ... }
|
|
|
3427
3444
|
|
|
3428
3445
|
**Sub-agent auth-scope inheritance and permissions clamp (v4.2).** When a tool handler constructs a sub-agent with `Parse::Agent.new(parent: agent, ...)`, the sub inherits `session_token` and `tenant_id` from the parent unless explicitly overridden. Without this inheritance, a session-token parent would silently produce a master-key sub-agent — the constructor default `session_token: nil` resolves to master-key mode — escalating privilege through the very kwarg meant to close sub-agent footguns. Explicit overrides still work (`Parse::Agent.new(parent: agent, session_token: nil)` produces a master-key sub if that is genuinely what the handler wants), but the default is fail-safe inheritance. `permissions:` is NOT inherited and defaults to `:readonly`, but the constructor enforces a clamp: an explicit `permissions:` override on a sub-agent is accepted only if `≤ parent.permissions`, otherwise `ArgumentError` is raised at construction. The clamp is the structural guarantee that a delegation chain cannot escape the parent's tier through sub-agent construction. See [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42) for the full inheritance table.
|
|
3429
3446
|
|
|
3430
|
-
**Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction
|
|
3447
|
+
**Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction. If the server is unreachable then, the agent retries on the first tool call that needs its permissions; if that also fails, the call raises `Parse::Agent::UnresolvedIdentity` (reported as `:access_denied`, `kind: :unresolved_identity`) instead of running without the SDK-side check, and further calls within the next few seconds are refused without another lookup (5.8.2). `acl_user:` takes a `Parse::User` or User-pointer and expands the user's role subscription via `Parse::Role.all_for_user`. It makes no token round-trip, and the SDK enforces the resulting `_rperm` filter itself. `acl_role:` is service-account-style scoping: no user_id, just the role plus parent-role inheritance. Master-key posture (none of the three supplied) remains the default and still emits the one-time `[Parse::Agent:SECURITY]` banner at construction. Every built-in tool reads `agent.acl_scope_kwargs` (single point of truth) to forward identity into `Parse::MongoDB.aggregate`, `Parse::Query#results_direct`, and `Parse::AtlasSearch.{search,autocomplete}`. Developer-registered tool handlers and `agent_method` bodies can reach `agent.acl_scope`, `agent.acl_permission_strings`, `agent.acl_read_match_stage` (a `_rperm` `$match`), or `agent.acl_write_match_stage` (a `_wperm` `$match`) to apply the agent's identity to their own queries.
|
|
3431
3448
|
|
|
3432
3449
|
**ACL composition on the mongo-direct aggregate path (v4.4.0).** When `aggregate` routes through `Parse::MongoDB.aggregate` (the default when `Parse::MongoDB.enabled?` is true), the agent layer derives the auth posture from the agent instance and forwards it to ACLScope — session-tokened / acl_user / acl_role agents get the same row-level `_rperm` `$match` injection regardless of identity mode; master-key agents pass `master: true` (the agent's class/field/tenant/canonical-filter gates are the security boundary for that posture). The posture is built in `Parse::Agent#acl_scope_kwargs`, not from tool-call JSON arguments; LLM-supplied `master:`, `session_token:`, `acl_user:`, or `acl_role:` kwargs are silently swallowed by the tool signature's `**_kwargs` catchall and never reach `Parse::MongoDB.aggregate`. An LLM cannot escalate from a scoped posture to master-key by injecting `master: true` into the tool arguments.
|
|
3433
3450
|
|
|
@@ -287,6 +287,9 @@ raw = Parse::MongoDB.find(
|
|
|
287
287
|
Convenience wrapper around `db.find`. Accepts `limit:`, `skip:`, `sort:`,
|
|
288
288
|
`projection:`, `hint:`, `max_time_ms:`. When `:limit` is omitted the call applies
|
|
289
289
|
`DEFAULT_FIND_LIMIT = 1000` and warns; pass `limit: 0` to opt out.
|
|
290
|
+
`find` reads raw documents unscoped, so it raises `Parse::ACLScope::ACLRequired`
|
|
291
|
+
inside `Parse.without_master_key`; `Parse::MongoDB.indexes` (metadata only) is
|
|
292
|
+
allowed there.
|
|
290
293
|
|
|
291
294
|
### Forcing an index with `hint`
|
|
292
295
|
|
|
@@ -761,6 +764,32 @@ legitimate read stages — but they read from arbitrary collections. Never
|
|
|
761
764
|
pass attacker-controlled input into a pipeline; build the pipeline in
|
|
762
765
|
trusted code and interpolate only validated values.
|
|
763
766
|
|
|
767
|
+
#### Regex patterns and the default time limit
|
|
768
|
+
|
|
769
|
+
Every caller-supplied regex on these paths (`$regex` strings, Ruby
|
|
770
|
+
`Regexp` and BSON regex values, and the `regex` operand of `$regexMatch`,
|
|
771
|
+
`$regexFind`, and `$regexFindAll`) goes through `Parse::RegexSecurity`,
|
|
772
|
+
which parses the pattern and refuses shapes that backtrack heavily on
|
|
773
|
+
PCRE: nested or overlapping repeats such as `(a+)+` or `(a|aa)+`, more
|
|
774
|
+
than three unbounded quantifiers in a row with no separating literal
|
|
775
|
+
(`\d+\d+\d+\d+`), subroutine calls (`\g<1>`), inline comments, and
|
|
776
|
+
extended mode. Escaped literal text, such as the patterns `starts_with`,
|
|
777
|
+
`ends_with`, and `contains` build, always passes the shape check.
|
|
778
|
+
|
|
779
|
+
A pattern check cannot prove every regex is cheap, so mongo-direct reads
|
|
780
|
+
whose filter or pipeline carries a regex that is not escaped literal text
|
|
781
|
+
also get a server-side `max_time_ms` of
|
|
782
|
+
`Parse::PipelineSecurity.default_regex_max_time_ms` (5000 ms by default)
|
|
783
|
+
when the caller passed none. This covers `Parse::MongoDB.aggregate` and
|
|
784
|
+
`find`, `results_direct`, `count_direct`, Atlas Search, and vector and
|
|
785
|
+
hybrid search. Set the attribute to a different value, or to `nil` to
|
|
786
|
+
turn the default off; an explicit `max_time_ms:` always wins. A query that
|
|
787
|
+
runs past the limit raises `Parse::MongoDB::ExecutionTimeout`.
|
|
788
|
+
|
|
789
|
+
REST queries are executed by Parse Server, which applies no regex check of
|
|
790
|
+
its own. Set `databaseOptions.maxTimeMS` in the Parse Server configuration
|
|
791
|
+
to bound those.
|
|
792
|
+
|
|
764
793
|
### Layer 2: Row-level ACL enforcement (`Parse::ACLScope`) — scoped only
|
|
765
794
|
|
|
766
795
|
When `Parse::MongoDB.aggregate` is called with `session_token:`,
|
data/docs/webhooks_guide.md
CHANGED
|
@@ -210,6 +210,36 @@ run Parse::Webhooks
|
|
|
210
210
|
See [`examples/webhook_server.rb`](../examples/webhook_server.rb) for a complete,
|
|
211
211
|
runnable setup.
|
|
212
212
|
|
|
213
|
+
### Authenticating requests, and what `master?` means
|
|
214
|
+
|
|
215
|
+
Requests are accepted only when `X-Parse-Webhook-Key` matches
|
|
216
|
+
`Parse::Webhooks.key` (`PARSE_SERVER_WEBHOOK_KEY`). With no key configured the
|
|
217
|
+
Rack app refuses every request unless you opt in with
|
|
218
|
+
`Parse::Webhooks.allow_unauthenticated = true` (or
|
|
219
|
+
`PARSE_WEBHOOK_ALLOW_UNAUTHENTICATED=true`), which is meant for local
|
|
220
|
+
development and tests. A signing secret does not replace the key: with no key
|
|
221
|
+
configured, a signed request is still refused unless `allow_unauthenticated`
|
|
222
|
+
is on. When both are configured, the key must match and the signature must
|
|
223
|
+
verify.
|
|
224
|
+
|
|
225
|
+
`payload.master?` is true only when the body says the master key was used (a
|
|
226
|
+
JSON `true`; Parse Server never sends a string) AND
|
|
227
|
+
the request was authenticated: the webhook key matched, or a configured
|
|
228
|
+
signature (`PARSE_WEBHOOK_SIGNING_SECRET`) verified. On unauthenticated ingress
|
|
229
|
+
any caller can put `"master": true` in the body, so `master?` returns false
|
|
230
|
+
there. Master-only field guards, ACL owner resolution, and handler checks
|
|
231
|
+
built on `master?` therefore treat such a request as a normal client request.
|
|
232
|
+
The Ruby-initiated callback dedup uses `master?` too, so a forged `_RB_`
|
|
233
|
+
request id plus `"master": true` cannot make a request skip model callbacks
|
|
234
|
+
(including a `before_save` that would reject it). `payload.claimed_master?`
|
|
235
|
+
returns the body's raw claim and `payload.authenticated?` reports whether the
|
|
236
|
+
request was authenticated; both are for diagnostics only.
|
|
237
|
+
|
|
238
|
+
The trade-off: on unauthenticated ingress, a save made by the SDK itself is
|
|
239
|
+
not recognized as Ruby-initiated, so its model callbacks can run twice (once
|
|
240
|
+
in process, once through the webhook). Configure a webhook key or a signing
|
|
241
|
+
secret to keep the dedup.
|
|
242
|
+
|
|
213
243
|
## Auditing trigger coverage
|
|
214
244
|
|
|
215
245
|
The wiring above has three independent moving parts, and a callback runs
|
|
@@ -284,13 +314,65 @@ database untouched. When the handler changed fields, the reply is the client's
|
|
|
284
314
|
full write with your changes layered on top: operators on fields you did not
|
|
285
315
|
touch are passed through as operators, fields the model does not declare (and
|
|
286
316
|
`_User` signup fields such as `password` and `authData`) are kept, and a field
|
|
287
|
-
you set back to its stored value is dropped from the write.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
317
|
+
you set back to its stored value is dropped from the write. A field you
|
|
318
|
+
rewrite is written as an absolute value. Edits made in place on `parse_object`
|
|
319
|
+
count even if the handler returns `true`.
|
|
320
|
+
|
|
321
|
+
An operator on a dotted sub-key (`"meta.count"`) arrives only as its resulting
|
|
322
|
+
sub-document, so on an update the SDK writes back just the sub-keys that
|
|
323
|
+
changed, as dotted paths, and a removed sub-key as a `Delete`. The split is one
|
|
324
|
+
level deep: a changed sub-key is written whole at `field.sub`, nested objects
|
|
325
|
+
included, because Parse Server rebuilds the afterSave object from a dotted key
|
|
326
|
+
only one level deep. Concurrent writes to other sub-keys of the field survive.
|
|
327
|
+
Some limits remain:
|
|
328
|
+
|
|
329
|
+
- Two concurrent writes inside the same sub-key can still overwrite each
|
|
330
|
+
other, including two operators on deeper paths under it
|
|
331
|
+
(`meta.count.value` and `meta.count.other`).
|
|
332
|
+
- An array is written whole, so a concurrent `Add` to an array inside a
|
|
333
|
+
sub-document is lost.
|
|
334
|
+
- When a changed sub-key would carry a typed value (a Date, Bytes, Pointer, or
|
|
335
|
+
other `__type` value), the whole field is written instead, because Parse
|
|
336
|
+
Server stores a dotted typed value without converting it. Unchanged typed
|
|
337
|
+
values and deletes do not trigger this.
|
|
338
|
+
- A client that replaces a whole sub-document gets a merge: the changed
|
|
339
|
+
sub-keys are written and the sub-keys it knew about and removed are
|
|
340
|
+
deleted, so a sub-key another request added in the meantime survives.
|
|
341
|
+
- Parse Server reports the reply's dotted keys back to the client in the save
|
|
342
|
+
response. The JavaScript SDK applies them; other clients may need to fetch
|
|
343
|
+
the object to see the merged value.
|
|
344
|
+
- A dotted key in a Hash the handler returns (`{ "meta.y" => 5 }`) is folded
|
|
345
|
+
into the parent path the reply writes whole. A key deeper than one level
|
|
346
|
+
(`"meta.count.value"`) is folded into a `meta.count` write built from the
|
|
347
|
+
pending object, and on a create every dotted key folds into its whole
|
|
348
|
+
field. An operator folded this way (`{"__op" => "Increment"}`, `Add`,
|
|
349
|
+
`AddUnique`, `Remove`, `Delete`) is applied to the value it replaces; an
|
|
350
|
+
operator that cannot be applied there, or two of your own keys that
|
|
351
|
+
conflict (`"meta.a" => 5` with `"meta.a.b" => 6`), fails the save with an
|
|
352
|
+
error. Hash keys may use Ruby names (`acl:`); they are mapped to the
|
|
353
|
+
stored field names.
|
|
354
|
+
|
|
355
|
+
On a create, returning `parse_object` also writes the model's declared
|
|
356
|
+
defaults for fields the client did not send, including its ACL policy. Parse
|
|
357
|
+
Server stores a create without an `ACL` as public read and write, so the reply
|
|
358
|
+
always carries the ACL your policy resolves, exactly as an SDK-side create
|
|
359
|
+
would. Owner-based policies (`:owner_else_private`, the default, as well as
|
|
360
|
+
`:owner_else_public` and `:owner_but_public_read`) take the owner from the
|
|
361
|
+
declared `owner:` field and, when that is empty, from the user who made the
|
|
362
|
+
request, so a signed-in client owns what it creates. A request without a user
|
|
363
|
+
gets the policy's fallback (master-key only under `:owner_else_private`), and so
|
|
364
|
+
does a master-key request. A `_User` class declared with `owner: :self` is
|
|
365
|
+
owned by the new user, never by the requester: the reply carries the policy's
|
|
366
|
+
ACL without an owner entry and Parse Server adds the new user's own read and
|
|
367
|
+
write. An ACL the client sent is kept, and an ACL the handler sets always
|
|
368
|
+
wins, whether through `acl=` or mass assignment. A `guard :acl, :master_only`
|
|
369
|
+
revert on a create returns the ACL to the class policy rather than to `{}`.
|
|
370
|
+
Built-in classes such as `_User` that have no policy of their own keep Parse
|
|
371
|
+
Server's defaults. A handler that returns `true`, `nil`, or a Hash keeps the client's
|
|
372
|
+
write as sent, so a create without an ACL stays public; return
|
|
373
|
+
`parse_object` to apply the policy. An ACL the handler assigns on
|
|
374
|
+
`parse_object` (`parse_object.acl = Parse::ACL.new`) is written whatever the
|
|
375
|
+
handler returns, unless a returned Hash sets `ACL` itself.
|
|
294
376
|
|
|
295
377
|
`after_destroy` callbacks run in the `after_delete` handler (once per delivery,
|
|
296
378
|
skipped for deletes the SDK itself made, whose callbacks already ran locally).
|
|
@@ -434,8 +516,11 @@ master key). It has already run the model's `before_save` / `after_save` /
|
|
|
434
516
|
The intent is to keep trigger logic local when possible and run it exactly once.
|
|
435
517
|
Note that any logic in the **webhook block itself** still runs; only the
|
|
436
518
|
duplicate ActiveModel callback pass is skipped. A spoofed `_RB_` marker without
|
|
437
|
-
the master key does not get this treatment
|
|
438
|
-
|
|
519
|
+
the master key does not get this treatment, and neither does a claimed master
|
|
520
|
+
key on an unauthenticated request (no webhook key match and no verified
|
|
521
|
+
signature): the callbacks run in the webhook as usual. On unauthenticated
|
|
522
|
+
ingress this means SDK-initiated saves run their callbacks twice; configure a
|
|
523
|
+
webhook key or signing secret to keep the dedup.
|
|
439
524
|
|
|
440
525
|
### 2. Server-initiated replay / freshness protection (inbound)
|
|
441
526
|
|