parse-stack-next 5.7.5 → 5.8.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/CHANGELOG.md +856 -0
- data/README.md +15 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +190 -14
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +318 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +93 -1
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +21 -3
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +290 -17
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +72 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
data/docs/mcp_guide.md
CHANGED
|
@@ -396,6 +396,177 @@ Common uses for the direct dispatcher:
|
|
|
396
396
|
|
|
397
397
|
---
|
|
398
398
|
|
|
399
|
+
## Deployment Patterns
|
|
400
|
+
|
|
401
|
+
`MCPRackApp.new(agent_factory: ...)` accepts any factory. For the two common
|
|
402
|
+
production shapes, two factories package the identity rules so a deployment
|
|
403
|
+
cannot drift from one access mode into the other by accident:
|
|
404
|
+
|
|
405
|
+
| Factory | Use it for | Identity | Data access |
|
|
406
|
+
|---|---|---|---|
|
|
407
|
+
| `MCPRackApp.user_scoped` | Personal assistants, application users | A Parse session token on every request; missing or invalid is 401 | The user's own ACL/CLP, enforced by Parse Server on REST and by the SDK on mongo-direct paths. No master-key fallback |
|
|
408
|
+
| `MCPRackApp.master_analytics` | Analytics, reporting, trusted operational tools | A verified operator from a required `principal_resolver`; unresolved is 401 | Master authority, narrowed by the agent's `tools:`, `classes:`, `filters:`, and field policy. Read-only by default |
|
|
409
|
+
|
|
410
|
+
Both factories take `agent_options:` (extra `Parse::Agent.new` options such
|
|
411
|
+
as `tools:`, `methods:`, `classes:`, `filters:`) and pass the remaining
|
|
412
|
+
keywords to `MCPRackApp.new` (`transport:`, `logger:`, `allowed_origins:`,
|
|
413
|
+
...). Options that set identity or authority are refused in
|
|
414
|
+
`agent_options:` with `ArgumentError` at construction: `session_token`,
|
|
415
|
+
`acl_user`, `acl_role`, `impersonate_user`, `impersonation_user`,
|
|
416
|
+
`impersonate_mint`, `impersonation_mint`, `impersonate_label`,
|
|
417
|
+
`impersonation_label`, `tenant_id`, `client`, `permissions`, `permission`, and
|
|
418
|
+
`parent`. `user_scoped` also refuses `master_atlas` and `allow_mutations`,
|
|
419
|
+
since a signed-in user's agent never receives authority beyond its session.
|
|
420
|
+
The factory owns the Rack options `agent_factory:`, `principal_resolver:`,
|
|
421
|
+
`listening_stream_revalidator:`, and `listening_stream_revalidate_interval:`,
|
|
422
|
+
so passing any of them is refused as well. A deliberate single-operator
|
|
423
|
+
master-key endpoint is still built with `MCPRackApp.new` directly.
|
|
424
|
+
|
|
425
|
+
### Personal assistant (user-scoped)
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
# config/routes.rb (Rails), or `run` it from config.ru
|
|
429
|
+
MCP_APP = Parse::Agent::MCPRackApp.user_scoped(
|
|
430
|
+
transport: :streamable_http,
|
|
431
|
+
permissions: :write, # writes still go only through agent_methods
|
|
432
|
+
tenant_from: ->(env, user_id) { Workspace.id_for_user(user_id) }, # pinned server-side
|
|
433
|
+
agent_options: { classes: %w[Post Comment], methods: %w[archive] },
|
|
434
|
+
)
|
|
435
|
+
mount MCP_APP, at: "/mcp"
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
# The only write this assistant can make, declared by the application.
|
|
440
|
+
class Post < Parse::Object
|
|
441
|
+
property :archived, :boolean
|
|
442
|
+
property :archive_reason, :string
|
|
443
|
+
|
|
444
|
+
agent_method :archive, permission: :write, supports_dry_run: true, permitted_keys: [:reason]
|
|
445
|
+
def archive(reason:, agent: nil, dry_run: false, **)
|
|
446
|
+
return { would: "archive #{id}", reason: reason } if dry_run
|
|
447
|
+
self.archived = true
|
|
448
|
+
self.archive_reason = reason
|
|
449
|
+
save
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Require a human approval (MCP elicitation) before any :write call runs.
|
|
454
|
+
Parse::Agent.require_approval_for = [:write]
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
The client sends the user's session token as `Authorization: Bearer
|
|
458
|
+
<token>` (or `X-Parse-Session-Token`; pass `session_token_from: ->(env) {
|
|
459
|
+
... }` for anything else). Every request builds a fresh
|
|
460
|
+
`Parse::Agent.new(session_token: token, ...)`, so the agent acts as that user
|
|
461
|
+
and tool arguments cannot change who it is or which tenant it is bound to.
|
|
462
|
+
`tenant_from` returning nil refuses the request rather than running unscoped.
|
|
463
|
+
|
|
464
|
+
On logout the token stops validating, so the next request gets 401 and any
|
|
465
|
+
open listening stream closes within the revalidation interval. To reconnect,
|
|
466
|
+
the client logs in again and starts a new MCP session (`initialize`) with the
|
|
467
|
+
new token. A session id is bound to the principal that initialized it, so
|
|
468
|
+
the new token cannot take over the old session, and the old session's
|
|
469
|
+
subscriptions are torn down when its stream closes (or reaped as orphans,
|
|
470
|
+
below).
|
|
471
|
+
|
|
472
|
+
### Read-only analytics endpoint (master key)
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
ANALYTICS = Parse::Agent::MCPRackApp.master_analytics(
|
|
476
|
+
# Required. Resolve the VERIFIED operator behind the request. Returning
|
|
477
|
+
# nil or "" refuses the request with 401.
|
|
478
|
+
principal_resolver: ->(_agent, env) { env["warden"]&.user&.email },
|
|
479
|
+
transport: :streamable_http,
|
|
480
|
+
agent_options: {
|
|
481
|
+
classes: %w[Post Subscription PostMetric],
|
|
482
|
+
tools: %w[query_class count_objects group_by group_by_date distinct aggregate get_schema],
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Audit: every tool call emits parse.agent.tool_call.
|
|
487
|
+
ActiveSupport::Notifications.subscribe("parse.agent.tool_call") do |*, payload|
|
|
488
|
+
AuditLog.record(tool: payload[:tool], correlation_id: payload[:correlation_id])
|
|
489
|
+
end
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
`principal_resolver:` is a required keyword: `master_analytics` raises
|
|
493
|
+
`ArgumentError` at construction when it is missing or does not respond to
|
|
494
|
+
`#call`. Without one every master-key agent has the same
|
|
495
|
+
fingerprint, so two operators sharing the endpoint could attach to, approve,
|
|
496
|
+
or cancel each other's sessions. The operator identity governs session
|
|
497
|
+
ownership and audit; master authority governs what data the agent can reach,
|
|
498
|
+
and the configured tools and classes govern what it may actually do. It
|
|
499
|
+
defaults to `permissions: :readonly`.
|
|
500
|
+
|
|
501
|
+
The master-key check runs per request, not at construction. The factory
|
|
502
|
+
resolves its client on each request (the `client:` you passed, else
|
|
503
|
+
`Parse.client` at that moment), and any request whose client has no master key
|
|
504
|
+
is answered with 401. Construction succeeds even when the master key is not
|
|
505
|
+
configured yet.
|
|
506
|
+
|
|
507
|
+
### Session ownership
|
|
508
|
+
|
|
509
|
+
Under both factories, an `Mcp-Session-Id` is bound to the principal that
|
|
510
|
+
initialized it (the session token for `user_scoped`, the resolved operator for
|
|
511
|
+
`master_analytics`). Another caller who knows or chooses the same id cannot:
|
|
512
|
+
|
|
513
|
+
- re-initialize it (403),
|
|
514
|
+
- attach its listening stream (403),
|
|
515
|
+
- cancel its in-flight requests (`notifications/cancelled` is a silent 202 no-op),
|
|
516
|
+
- answer its approval prompts (the elicitation reply is a silent 202 no-op),
|
|
517
|
+
- change its log level.
|
|
518
|
+
|
|
519
|
+
A session with an attached listening stream or a pending approval keeps its
|
|
520
|
+
owner binding for as long as it is live. When the owner registry is full of
|
|
521
|
+
live sessions, a new `initialize` or listening stream is refused with `503`
|
|
522
|
+
rather than displacing one (see Capacity and eviction under the listening
|
|
523
|
+
stream's owner binding, below).
|
|
524
|
+
|
|
525
|
+
### Revocation intervals
|
|
526
|
+
|
|
527
|
+
How quickly a logged-out, expired, or revoked session (or a role change)
|
|
528
|
+
stops having effect, for a `user_scoped` deployment:
|
|
529
|
+
|
|
530
|
+
| Path | `session_validation: :per_request` (default) | `session_validation: :cached` |
|
|
531
|
+
|---|---|---|
|
|
532
|
+
| New MCP request (any tool) | Next request: the token is re-checked against Parse Server (`GET /users/me`, response cache bypassed) | Identity-cache TTL (`identity_cache_ttl`, default 3600s), or immediately when the `Parse::Cache::Invalidation` `after_logout` / `_User` hooks evict the token |
|
|
533
|
+
| REST tools within an accepted request | Immediate: Parse Server validates the token on every call | Immediate |
|
|
534
|
+
| Mongo-direct reads (identity) | Next request: a failed check also evicts the token from `client.authorization` | Same as the row above |
|
|
535
|
+
| Mongo-direct reads (role change) | Role-cache TTL (`role_cache_ttl`, default 30s), or immediately when the `_Role` invalidation hook fires | Same |
|
|
536
|
+
| Open listening stream / subscriptions | Within `session_revalidate_interval` (default 60s): the stream is re-checked and closed, tearing down its LiveQuery subscriptions | Same (stream re-checks always ask Parse Server) |
|
|
537
|
+
|
|
538
|
+
`:per_request` costs one `/users/me` call per MCP request; `:cached` saves it
|
|
539
|
+
at the price of the identity-TTL window when invalidation hooks are not
|
|
540
|
+
installed. The TTLs are configured with
|
|
541
|
+
`Parse::Authorization.configure(identity_cache_ttl:, role_cache_ttl:)`.
|
|
542
|
+
|
|
543
|
+
### Orphaned subscriptions
|
|
544
|
+
|
|
545
|
+
A session that subscribes to resources but never opens its listening stream
|
|
546
|
+
(or subscribes again after the stream closed) holds LiveQuery subscriptions
|
|
547
|
+
with nowhere to deliver them. Normal teardown (unsubscribe, stream close,
|
|
548
|
+
`DELETE`) does not cover that case, so the subscription manager reaps such
|
|
549
|
+
sessions after a grace period: `orphan_ttl:` on
|
|
550
|
+
`Parse::Agent::MCPSubscriptions::Manager` (default 300 seconds, `nil` to
|
|
551
|
+
disable). Reaping runs whenever a session subscribes or attaches a stream,
|
|
552
|
+
and can be triggered with `manager.reap_orphans!`. A session that attaches
|
|
553
|
+
its stream within the grace period keeps its subscriptions.
|
|
554
|
+
|
|
555
|
+
### Operational notes for deployment factories
|
|
556
|
+
|
|
557
|
+
* **Validation load.** `user_scoped` with the default
|
|
558
|
+
`session_validation: :per_request` asks Parse Server (`GET /users/me`) on
|
|
559
|
+
every request, including requests carrying a random bearer string. Put a
|
|
560
|
+
`pre_auth_rate_limiter:` in front of a public endpoint, or use
|
|
561
|
+
`session_validation: :cached` and rely on the identity cache's TTL and
|
|
562
|
+
invalidation hooks.
|
|
563
|
+
* **Outages fail closed.** A Parse Server error while validating a session is
|
|
564
|
+
treated as an invalid session: the request gets 401 and a listening stream
|
|
565
|
+
is closed at its next revalidation. Clients reconnect once Parse Server is
|
|
566
|
+
back.
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
399
570
|
## Connecting Claude Desktop (stdio bridge)
|
|
400
571
|
|
|
401
572
|
Parse Stack speaks MCP over **HTTP** (the standalone server and the
|
|
@@ -659,10 +830,36 @@ The principal fingerprint is derived, in order, from: an operator-supplied
|
|
|
659
830
|
per-`MCPRackApp` instance and **single-process** — it does not span Puma workers
|
|
660
831
|
or survive a restart. In a clustered deployment the `initialize` POST and the
|
|
661
832
|
`GET` stream may land on different workers, so the initialize-binding degrades
|
|
662
|
-
to TOFU there.
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
833
|
+
to TOFU there. Blank session ids or blank fingerprints fail closed.
|
|
834
|
+
|
|
835
|
+
**Capacity and eviction.** The registry is LRU-bounded (default 10,000
|
|
836
|
+
sessions) so a stream of `initialize`-without-`DELETE` sessions cannot grow it
|
|
837
|
+
without limit. Only idle bindings are evicted. A live session (one with an
|
|
838
|
+
attached listening stream or a pending approval prompt) is pinned and keeps its
|
|
839
|
+
owner binding under any amount of LRU pressure, so a flood of new sessions
|
|
840
|
+
cannot strip a victim's owner and then answer its approvals or cancel its
|
|
841
|
+
requests. An evicted idle id is unbound, and the next principal to attach a
|
|
842
|
+
stream to it claims it TOFU. When every binding is live and the registry is
|
|
843
|
+
full, new bindings are refused rather than displacing a live one: `initialize`
|
|
844
|
+
and the listening-stream `GET` answer `503` (`-32000`, "Session capacity
|
|
845
|
+
exhausted"). Clients retry once sessions close or are terminated with
|
|
846
|
+
`DELETE`.
|
|
847
|
+
|
|
848
|
+
**Per-principal bound.** Each principal may hold at most 100 session
|
|
849
|
+
bindings. Past that, its own least recently used idle binding is evicted (or
|
|
850
|
+
the new one refused with `503` when all of its bindings are live), so one
|
|
851
|
+
caller flooding `initialize` cannot push other principals' sessions out of
|
|
852
|
+
the registry. `initialize` and `resources/subscribe` are charged against the
|
|
853
|
+
principal's rate limiter and answer `429` when it is exhausted; with
|
|
854
|
+
`user_scoped` and `master_analytics` that limiter is shared across the
|
|
855
|
+
principal's requests.
|
|
856
|
+
|
|
857
|
+
**Requests on another principal's session.** Any POST whose `Mcp-Session-Id`
|
|
858
|
+
is bound to a different principal is refused with `403`.
|
|
859
|
+
`resources/subscribe` additionally requires a session the caller established,
|
|
860
|
+
through `initialize` or by attaching its listening stream, because each
|
|
861
|
+
subscription holds a LiveQuery socket and a slot in the global session limit.
|
|
862
|
+
A stateless client that never initializes can still call tools.
|
|
666
863
|
|
|
667
864
|
---
|
|
668
865
|
|
|
@@ -847,6 +1044,51 @@ session's logs. `DELETE` on the session forgets it.
|
|
|
847
1044
|
|
|
848
1045
|
---
|
|
849
1046
|
|
|
1047
|
+
## Server Field Names (`field_names: :server`)
|
|
1048
|
+
|
|
1049
|
+
`Parse::Agent.new(field_names: :server)` asks for data fields in the exact
|
|
1050
|
+
names Parse returns or the model declares through its `field_map`
|
|
1051
|
+
(`createdAt`, `totalPlays`, an alias such as `ExternalID`), with no
|
|
1052
|
+
snake_case conversion anywhere a tool would otherwise apply one. Omit it (or
|
|
1053
|
+
pass `:default`) to keep each tool's existing output; any other value raises
|
|
1054
|
+
`ArgumentError`. Sub-agents inherit the mode unless they set their own.
|
|
1055
|
+
|
|
1056
|
+
```ruby
|
|
1057
|
+
analytics = Parse::Agent.new(field_names: :server, permissions: :readonly)
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
| Output | Default | `field_names: :server` |
|
|
1061
|
+
|---|---|---|
|
|
1062
|
+
| `query_class` / `get_object` rows, Atlas and `semantic_search` source records, exports (CSV headers) | Parse field names | Parse field names (unchanged) |
|
|
1063
|
+
| A Parse object returned by an `agent_method` (`call_method`) | Parse field names and values | Parse field names and values |
|
|
1064
|
+
| An `AggregationResult` returned by an `agent_method` | snake_case keys | keys exactly as the aggregation returned them |
|
|
1065
|
+
|
|
1066
|
+
Most tool output already carried server names, so the visible difference is
|
|
1067
|
+
in values an `agent_method` returns. Two `call_method` serialization fixes
|
|
1068
|
+
ship alongside: a returned Parse object is now emitted with its values (it
|
|
1069
|
+
previously emitted the model's field-type map, e.g. `"title" => "string"`),
|
|
1070
|
+
and a returned `AggregationResult` is emitted as a Hash (it was previously its
|
|
1071
|
+
`inspect` String).
|
|
1072
|
+
|
|
1073
|
+
The option changes **data-field keys only**:
|
|
1074
|
+
|
|
1075
|
+
* MCP protocol keys and SDK envelope keys (`chunks`, `documents`,
|
|
1076
|
+
`object_id`, `next_call`, ...) keep their contracts.
|
|
1077
|
+
* Date, Pointer, and File formatting, vector visibility, and metadata
|
|
1078
|
+
redaction are unchanged.
|
|
1079
|
+
* "Server names" means the application-facing Parse names, never raw MongoDB
|
|
1080
|
+
storage columns such as `_p_author`, `_rperm`, or `_session_token`.
|
|
1081
|
+
* Explicit export column aliases still win over automatic naming.
|
|
1082
|
+
|
|
1083
|
+
Naming is presentation only. ACL/CLP, `protectedFields`, the class
|
|
1084
|
+
`agent_fields` ceiling, and per-agent `fields:` policies resolve against the
|
|
1085
|
+
canonical field names before anything is formatted, so a user-scoped agent
|
|
1086
|
+
and a master-key agent with the same policy return the same fields in either
|
|
1087
|
+
mode. Like `fields:`, the mode is scoped to each tool call, so agents with
|
|
1088
|
+
different modes can run concurrently in one process.
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
850
1092
|
## Built-in Agent Hardening & Telemetry
|
|
851
1093
|
|
|
852
1094
|
5.2 adds several agent-side controls, all configured on `Parse::Agent`:
|
|
@@ -912,6 +1154,7 @@ front avoids discovering each only on impact:
|
|
|
912
1154
|
| Reserved underscore key | A `filter:` / `vector_filter:` / `where:` contains an underscore-prefixed key (`_rperm`, `_p_*`, …) at any depth | `ArgumentError` / `ValidationError` (recursive refusal) |
|
|
913
1155
|
| Filter-field allowlist | A `filter:` / `vector_filter:` names a field not in the class's `agent_searchable filter_fields:` | `ValidationError` naming the offending field(s) |
|
|
914
1156
|
| `text_field` not embedded | `semantic_search` `text_field:` names a field that isn't a declared `embed` source | `ValidationError` listing the allowed sources |
|
|
1157
|
+
| `text_field` hidden | The `semantic_search` text source (explicit or inferred) is an `embed` source outside the class's `agent_fields` allowlist | `Parse::Agent::AccessDenied` (`kind: :field_denied`), before any search runs |
|
|
915
1158
|
| Tool filtered | A tool/method removed by a per-instance `tools:` / `methods:` filter is invoked | `error_code: :tool_filtered` |
|
|
916
1159
|
| Approval denied/unavailable | A gated write/admin op is rejected or the approver is unreachable | `error_code: :approval_denied` |
|
|
917
1160
|
|
|
@@ -1094,6 +1337,12 @@ MCPRackApp (per-request factory)
|
|
|
1094
1337
|
|
|
1095
1338
|
The same problem exists in miniature whenever a tool handler constructs a sub-agent inside its block — a fresh `Parse::Agent.new` produces a fresh limiter, so an attacker who can induce delegation amplifies the per-process budget linearly with delegation depth × branching. The v4.2 `parent:` kwarg closes that case automatically (see [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42)); the shared external limiter pattern below covers the cross-request case at the MCPRackApp boundary.
|
|
1096
1339
|
|
|
1340
|
+
### Deployment factories already share a limiter per principal
|
|
1341
|
+
|
|
1342
|
+
`MCPRackApp.user_scoped` and `MCPRackApp.master_analytics` do not have this problem. Each keeps one in-process `RateLimiter` per principal (the validated user id for `user_scoped`, the resolved operator for `master_analytics`) and hands it to every agent it builds for that principal, so `rate_limit:` and `rate_window:` in `agent_options:` accumulate across requests. The registry is LRU-bounded (default 10,000 principals); an evicted principal starts a fresh window. Passing your own `rate_limiter:` in `agent_options:` bypasses the registry and uses that limiter as-is, which is the way to share one budget across processes.
|
|
1343
|
+
|
|
1344
|
+
The workarounds below apply to a custom `agent_factory:` lambda (including `Parse::Agent.rack_app do |env| ... end`), which builds a fresh agent per request and gets no shared limiter unless you inject one.
|
|
1345
|
+
|
|
1097
1346
|
### The solution
|
|
1098
1347
|
|
|
1099
1348
|
Inject a shared, externally-stateful limiter:
|
|
@@ -1415,7 +1664,9 @@ class Project < Parse::Object
|
|
|
1415
1664
|
# _wperm so the update only sees rows the agent's scope is allowed
|
|
1416
1665
|
# to modify, defense-in-depth alongside Parse Server's own ACL.
|
|
1417
1666
|
Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope
|
|
1418
|
-
|
|
1667
|
+
self.archived = true # property :archived, :boolean
|
|
1668
|
+
self.archive_reason = reason # property :archive_reason, :string
|
|
1669
|
+
save
|
|
1419
1670
|
{ archived: true, objectId: id }
|
|
1420
1671
|
end
|
|
1421
1672
|
end
|
|
@@ -1557,6 +1808,67 @@ Parse::Agent::Tools.register(
|
|
|
1557
1808
|
|
|
1558
1809
|
**ACL-scope subset invariant (v4.4.0):** when the parent carries a resolved ACL scope (session_token / acl_user / acl_role), an explicit child override must resolve to a `permission_strings` set that is a SUBSET of the parent's. A tool handler that tries `Parse::Agent.new(parent: user_scoped, acl_role: "admin")` raises `ArgumentError` at construction because the child's claim set would include `"role:admin"`, which the parent's claim set does not. The same applies to a different `acl_user:` (different user_id), or to a child that resolves to master-key while the parent was scoped. This closes the analogous footgun for the acl_user / acl_role identity axis — the precedent of session_token swap is misleading because session tokens are externally verified by Parse Server, while `acl_user:` and `acl_role:` are unverified constructor assertions. A master-key parent (`@acl_scope.nil?`) allows any child scope because the parent already has unrestricted reach.
|
|
1559
1810
|
|
|
1811
|
+
### `fields:` per-agent field narrowing (5.8)
|
|
1812
|
+
|
|
1813
|
+
A class's `agent_fields` is the most any agent may read. `fields:` narrows
|
|
1814
|
+
that ceiling for one agent, so two MCP deployments in the same process can
|
|
1815
|
+
expose different subsets of the same model: a user-facing assistant sees
|
|
1816
|
+
less than an analytics endpoint. Effective access is the intersection of the
|
|
1817
|
+
caller's Parse ACL/CLP, the class `agent_fields`, the agent's `fields:`
|
|
1818
|
+
policy, its tenant scope, and its tool and method filters.
|
|
1819
|
+
|
|
1820
|
+
```ruby
|
|
1821
|
+
class Customer < Parse::Object
|
|
1822
|
+
property :display_name, :string
|
|
1823
|
+
property :timezone, :string
|
|
1824
|
+
property :plan, :string
|
|
1825
|
+
property :billing_email, :string
|
|
1826
|
+
agent_fields :display_name, :timezone, :plan, :billing_email # ceiling for every agent
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1829
|
+
assistant = Parse::Agent.new(session_token: token,
|
|
1830
|
+
fields: { Customer => %i[display_name timezone] })
|
|
1831
|
+
analytics = Parse::Agent.new(permissions: :readonly,
|
|
1832
|
+
fields: { Customer => %i[display_name plan] })
|
|
1833
|
+
```
|
|
1834
|
+
|
|
1835
|
+
* **Narrow only.** A field outside `agent_fields` stays hidden even if a
|
|
1836
|
+
policy lists it. A class without `agent_fields` is narrowed to exactly the
|
|
1837
|
+
listed fields (plus `objectId`, `createdAt`, `updatedAt`).
|
|
1838
|
+
* **`default:`** narrows every class the policy does not name.
|
|
1839
|
+
* **Sub-agents intersect.** `Parse::Agent.new(parent: assistant, fields: ...)`
|
|
1840
|
+
sees only fields both policies permit; omitting `fields:` inherits the
|
|
1841
|
+
parent's policy.
|
|
1842
|
+
* **Everywhere the class allowlist applied.** Query projection, `keys:`,
|
|
1843
|
+
include projections, aggregation pipelines, Atlas Search fields,
|
|
1844
|
+
`get_schema` and `completion/complete` field names, exports,
|
|
1845
|
+
`agent.describe`, and `semantic_search` chunk text, reranker input, and
|
|
1846
|
+
filter fields all use the effective set.
|
|
1847
|
+
* **No inference through filters.** `query_class`, `count_objects`, and
|
|
1848
|
+
`export_data` refuse a `where:` or `order:` on a field outside the
|
|
1849
|
+
effective set with `:field_denied` (filtering or sorting on a hidden field
|
|
1850
|
+
reveals it through which rows match). `group_by`, `distinct`, and
|
|
1851
|
+
aggregation already refused. This also applies to the class ceiling with
|
|
1852
|
+
no `fields:` policy.
|
|
1853
|
+
* **Writes stay on declared methods.** `fields:` governs what the agent
|
|
1854
|
+
reads; writes still go through `agent_method`s and the per-agent
|
|
1855
|
+
`methods:` filter.
|
|
1856
|
+
|
|
1857
|
+
The policy is applied for the duration of each tool call (fiber-local), so
|
|
1858
|
+
agents serving concurrent requests never see each other's policy.
|
|
1859
|
+
|
|
1860
|
+
**Known limits.**
|
|
1861
|
+
|
|
1862
|
+
* `call_method` projects returned objects (anything carrying `className` and
|
|
1863
|
+
`objectId`) through their class policy, but rows a method returns from its
|
|
1864
|
+
own aggregation (`$group` output, `$lookup` documents) are the method
|
|
1865
|
+
author's responsibility: the SDK cannot tell which class such a row came
|
|
1866
|
+
from.
|
|
1867
|
+
* `get_schema` lists index keys and class-level permission entries that may
|
|
1868
|
+
name fields outside the agent's policy (names only, never values).
|
|
1869
|
+
|
|
1870
|
+
---
|
|
1871
|
+
|
|
1560
1872
|
### Developer introspection — `agent.describe` / `describe_for` / `would_permit?` (v4.4.0)
|
|
1561
1873
|
|
|
1562
1874
|
Three helpers on every agent for answering "why is this agent refusing this call?" and "what can this agent actually see?" without parsing audit payloads or tracing through tool implementations. NOT exposed to the LLM — operator-side observability only.
|
|
@@ -2482,7 +2794,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
|
|
|
2482
2794
|
|
|
2483
2795
|
**Server-assigned on `initialize`:** when the client omits the header on the `initialize` request, `MCPRackApp` generates a UUID, binds it to `agent.correlation_id`, and returns it in the `Mcp-Session-Id` response header. Clients echo that id on subsequent requests. A client-supplied `Mcp-Session-Id` on `initialize` is echoed back unchanged; a factory-bound `correlation_id` always wins over both. Only the `initialize` response carries the header — non-init responses don't, so the id is never leaked on every reply. The SDK does not maintain a server-side session store: the id is best-effort correlation only (audit threading + cancellation routing), and a subsequent request carrying an "unknown" id is NOT refused.
|
|
2484
2796
|
|
|
2485
|
-
**Session termination via `DELETE /`:** a `DELETE` carrying `Mcp-Session-Id` cancels every in-flight request registered under that correlation id and returns `204 No Content`. The header value is sanitized with the same regex as the request setter; missing or invalid values return `400`.
|
|
2797
|
+
**Session termination via `DELETE /`:** a `DELETE` carrying `Mcp-Session-Id` cancels every in-flight request registered under that correlation id and returns `204 No Content`. The header value is sanitized with the same regex as the request setter; missing or invalid values return `400`. Since 5.8, termination is gated like every other session operation: it passes the Origin policy, authenticates through the agent factory (`401` when the factory refuses), and is refused with `403` when the session is bound to a different principal. An unclaimed session can be terminated by any authenticated caller.
|
|
2486
2798
|
|
|
2487
2799
|
- **Factory path (for application-bound sessions):** application code that already has an internal session identifier can override the client-supplied header by setting it inside the agent factory:
|
|
2488
2800
|
|
|
@@ -469,6 +469,33 @@ The `Parse::MongoDB.to_mongodb_date(value)` helper coerces `Date`,
|
|
|
469
469
|
`DateTime`, `Time`, ISO 8601 strings, and Unix timestamps to a UTC `Time`
|
|
470
470
|
suitable for matching.
|
|
471
471
|
|
|
472
|
+
### MongoDB 9.0 null semantics
|
|
473
|
+
|
|
474
|
+
MongoDB 9.0 changed how `null` comparisons treat a dotted path that runs
|
|
475
|
+
through an array. When the path resolves to no non-null value (the field is
|
|
476
|
+
an empty array, an array of scalars, or contains a nested array), it now
|
|
477
|
+
compares equal to `null`. For `{ meta: [] }` or `{ meta: [1] }`,
|
|
478
|
+
`{ "meta.owner": null }` now matches and `{ "meta.owner": { $ne: null } }` no
|
|
479
|
+
longer does. `$exists` is unchanged, and `$lookup` equality follows the new
|
|
480
|
+
rule. This applies to REST queries (Parse Server passes them to MongoDB) and
|
|
481
|
+
mongo-direct queries alike.
|
|
482
|
+
|
|
483
|
+
The SDK never adds a `null` comparison on a dotted path by itself; ACL
|
|
484
|
+
scoping only checks the top-level `_rperm`. It compiles one only when you
|
|
485
|
+
name a dotted field. Which constraints are affected:
|
|
486
|
+
|
|
487
|
+
| Constraint on a dotted field | Compiles to | Changed in 9.0 |
|
|
488
|
+
|---|---|---|
|
|
489
|
+
| `:"meta.owner" => nil` | `{ "meta.owner": null }` | Yes |
|
|
490
|
+
| `:"meta.owner".not => nil`, `.null => false` | `{ $ne: null }` | Yes |
|
|
491
|
+
| `:"meta.owner".in => [nil, ...]` | `{ $in: [null, ...] }` | Yes |
|
|
492
|
+
| `:"meta.items".empty_or_nil`, `.not_empty` | pipeline with `$eq` / `$ne: null` | Yes |
|
|
493
|
+
| `:"meta.owner".null => true`, `.exists => false` | `{ $exists: false }` | No |
|
|
494
|
+
|
|
495
|
+
If a dotted path in your queries can traverse an array, review those
|
|
496
|
+
queries before upgrading, or use `.exists` when you mean "the field is
|
|
497
|
+
present".
|
|
498
|
+
|
|
472
499
|
---
|
|
473
500
|
|
|
474
501
|
## Pointer joins and `parse_reference`
|
data/docs/usage_guide.md
CHANGED
|
@@ -118,6 +118,44 @@ Song.query.aggregate([
|
|
|
118
118
|
])
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
### Field names in aggregation results
|
|
122
|
+
|
|
123
|
+
Custom aggregation rows (`$group`, `$project`, ...) come back as
|
|
124
|
+
`Parse::AggregationResult`. By default `#to_h` converts field names to
|
|
125
|
+
snake_case symbols. Pass `field_names: :server` to `#results` to keep the
|
|
126
|
+
names exactly as the aggregation returned them (5.8):
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
agg = Song.query.aggregate([
|
|
130
|
+
{ "$group" => { "_id" => "$genre", "totalPlays" => { "$sum" => "$plays" } } },
|
|
131
|
+
])
|
|
132
|
+
|
|
133
|
+
row = agg.results.first
|
|
134
|
+
row.to_h # => { _id: "Rock", total_plays: 500 } (default)
|
|
135
|
+
|
|
136
|
+
row = agg.results(field_names: :server).first
|
|
137
|
+
row.to_h # => { "_id" => "Rock", "totalPlays" => 500 }
|
|
138
|
+
row["totalPlays"] # => 500
|
|
139
|
+
row.total_plays # => 500 (a snake_case name still resolves when it matches one key)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
In `:server` mode keys are Strings, nested values are untouched, and keys
|
|
143
|
+
whose snake_case forms collide all survive. A name that is itself a key always
|
|
144
|
+
wins: with both `"totalPlays"` and `"total_plays"` stored, `row.total_plays`
|
|
145
|
+
reads `"total_plays"`. A snake_case name that is not itself a key but matches
|
|
146
|
+
more than one key (`"totalPlays"` and `"TotalPlays"` both stored) is
|
|
147
|
+
ambiguous: the method form (`row.total_plays`) raises `ArgumentError` naming
|
|
148
|
+
the candidates instead of guessing, and `row["total_plays"]` returns nil. Read
|
|
149
|
+
such a field by its exact key. Parse::Object rows are unaffected; `#as_json` already
|
|
150
|
+
returns their server field names. This is a presentation option only, and it
|
|
151
|
+
is separate from:
|
|
152
|
+
|
|
153
|
+
* `#raw`, which returns the unwrapped Hash exactly as received;
|
|
154
|
+
* `Parse::Query.field_formatter`, which controls how field names in OUTGOING
|
|
155
|
+
queries are compiled, not response keys;
|
|
156
|
+
* the REST aggregate `raw_field_names:` / `raw_values:` flags, which change
|
|
157
|
+
what Parse Server itself returns (and are never enabled by `field_names:`).
|
|
158
|
+
|
|
121
159
|
## Transactions
|
|
122
160
|
|
|
123
161
|
```ruby
|
data/docs/webhooks_guide.md
CHANGED
|
@@ -87,10 +87,18 @@ one-to-one — the SDK maps between them.
|
|
|
87
87
|
- **`beforeFind` / `afterFind` are result-side, not object-side.** Unlike the
|
|
88
88
|
save/delete triggers, a find payload carries no single `object` — `beforeFind`
|
|
89
89
|
exposes the incoming `query` (via `payload.query`) and `afterFind` exposes the
|
|
90
|
-
matched rows (via `payload.objects`).
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
matched rows (via `payload.objects`). **Over an HTTP webhook, `afterFind` can
|
|
91
|
+
observe the rows or deny the query, but it cannot rewrite them.** Parse Server
|
|
92
|
+
passes every row a webhook returns through `toJSONwithObjects`, which turns
|
|
93
|
+
any plain JSON object into `{}`, so returned rows would reach the client
|
|
94
|
+
blank. The SDK therefore always replies "keep the rows" (`{}`, with no
|
|
95
|
+
`success` key). Returning `nil`, `true`, or the unchanged `payload.objects`
|
|
96
|
+
all pass the rows through. Returning a different set of rows (dropping or
|
|
97
|
+
adding some) denies the query with an error instead, so rows a handler meant
|
|
98
|
+
to hide are never returned. To restrict rows, constrain the query in
|
|
99
|
+
`beforeFind` or with ACLs/CLPs; to block a query, call `error!` or return
|
|
100
|
+
`false`. (In-process `Parse.Cloud.afterFind` cloud code is not subject to this
|
|
101
|
+
limit.) It also adds a webhook round-trip to every matching query, so
|
|
94
102
|
register it deliberately.
|
|
95
103
|
|
|
96
104
|
One non-obvious detail the SDK handles for you: **Parse Server does not put the
|
|
@@ -104,11 +112,9 @@ one-to-one — the SDK maps between them.
|
|
|
104
112
|
Because the class is resolved from the route, declared `:vector` columns are
|
|
105
113
|
stripped from `afterFind` `payload.objects` by default, exactly as they are
|
|
106
114
|
from `object`/`original`/`update` on the other triggers (a
|
|
107
|
-
`vector_visibility :public` class keeps them).
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
`as_json` default (an `owner_only` class never exposes vectors anyway). Return
|
|
111
|
-
your own array if you need different columns.
|
|
115
|
+
`vector_visibility :public` class keeps them). This affects only what the
|
|
116
|
+
handler sees. The rows the client receives are the ones Parse Server matched,
|
|
117
|
+
unchanged by the webhook.
|
|
112
118
|
|
|
113
119
|
- **Auth triggers (`beforeLogin` / `afterLogin` / `afterLogout` /
|
|
114
120
|
`beforePasswordResetRequest`) and LiveQuery triggers (`beforeConnect` /
|
|
@@ -259,10 +265,50 @@ abort "Webhook coverage gaps detected" if inert.positive?
|
|
|
259
265
|
## Returning a value from a handler
|
|
260
266
|
|
|
261
267
|
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.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
268
|
+
inside it you can call `parse_object`, `params`, `error!`, etc. directly. What
|
|
269
|
+
the handler returns decides the reply Parse Server receives:
|
|
270
|
+
|
|
271
|
+
| Trigger | Allow | Change | Reject |
|
|
272
|
+
|---------|-------|--------|--------|
|
|
273
|
+
| `before_save` | `true`, `nil`, or an unchanged `parse_object` | the mutated `parse_object`, or a Hash of field overrides | `false` or `error!` |
|
|
274
|
+
| `before_delete` | anything else | n/a | `false`, `error!`, or a `before_destroy` callback that returns `false` |
|
|
275
|
+
| `after_find` | `nil`, `true`, or the unchanged `payload.objects` | not possible over HTTP (see above) | `false` or `error!` |
|
|
276
|
+
| function | the return value is the function result | | `error!` |
|
|
277
|
+
|
|
278
|
+
How a `before_save` change reaches Parse Server: Parse Server *replaces* the
|
|
279
|
+
pending write with the object a beforeSave webhook returns. The SDK therefore
|
|
280
|
+
never replies with only your changes. When nothing changed, it replies "keep
|
|
281
|
+
the write as sent", so the client's atomic operators (`Increment`, `Add`,
|
|
282
|
+
`Remove`, `Delete`, relation operators) and any undeclared fields reach the
|
|
283
|
+
database untouched. When the handler changed fields, the reply is the client's
|
|
284
|
+
full write with your changes layered on top: operators on fields you did not
|
|
285
|
+
touch are passed through as operators, fields the model does not declare (and
|
|
286
|
+
`_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. Two limits come
|
|
288
|
+
from what Parse Server sends the webhook: a field you rewrite is written as an
|
|
289
|
+
absolute value, and an operator on a dotted sub-key (`"meta.count"`) arrives
|
|
290
|
+
only as its resulting sub-document and is written back that way. Edits made
|
|
291
|
+
in place on `parse_object` count even if the handler returns `true`. On a
|
|
292
|
+
create, returning `parse_object` also writes the model's declared defaults
|
|
293
|
+
(including its default ACL) for fields the client did not send.
|
|
294
|
+
|
|
295
|
+
`after_destroy` callbacks run in the `after_delete` handler (once per delivery,
|
|
296
|
+
skipped for deletes the SDK itself made, whose callbacks already ran locally).
|
|
297
|
+
`before_destroy` callbacks run in `before_delete` when the handler returns
|
|
298
|
+
`parse_object`.
|
|
299
|
+
|
|
300
|
+
`error!(message, code: 137)` attaches a Parse error code to the error reply
|
|
301
|
+
and to the raised `Parse::Webhooks::ResponseError#code`. Parse Server's HTTP
|
|
302
|
+
webhook adapter currently reports every webhook error to the client as code
|
|
303
|
+
141 (`SCRIPT_FAILED`), so the code is mainly useful to in-process callers such
|
|
304
|
+
as `Parse::Webhooks.run_function`. Any other exception a handler raises is
|
|
305
|
+
answered with a generic `{"error": "Webhook handler failed."}` reply (the
|
|
306
|
+
exception class and a redacted message are logged), so internal details never
|
|
307
|
+
reach the client.
|
|
308
|
+
|
|
309
|
+
A request for a function with no registered handler is answered with an
|
|
310
|
+
error. A trigger with no registered handler passes the operation through
|
|
311
|
+
unchanged.
|
|
266
312
|
|
|
267
313
|
You can set that value either with an explicit `return` or by letting it be the
|
|
268
314
|
block's last expression — both work:
|
|
@@ -396,10 +442,14 @@ usual.
|
|
|
396
442
|
This protects the webhook endpoint against **replayed inbound POSTs** —
|
|
397
443
|
`lib/parse/webhooks/replay_protection.rb`:
|
|
398
444
|
|
|
399
|
-
- **
|
|
400
|
-
`
|
|
401
|
-
|
|
402
|
-
|
|
445
|
+
- **Nonce-keyed dedup.** When a delivery carries an `X-Parse-Request-Id` or
|
|
446
|
+
`X-Parse-Webhook-Nonce` header, a bounded LRU records a digest of
|
|
447
|
+
`(nonce, body)`; a duplicate seen within `replay_window_seconds` is rejected
|
|
448
|
+
with `"Webhook replay detected."`. Parse Server sends neither header on its
|
|
449
|
+
own deliveries, so add one in the proxy or wrapper that delivers webhooks if
|
|
450
|
+
you want dedup. Without a nonce the SDK does not dedup on the body alone:
|
|
451
|
+
two legitimate identical requests (the same function called twice with the
|
|
452
|
+
same params) cannot be told apart from a replay that way.
|
|
403
453
|
- **Opt-in HMAC freshness verification.** Set a `signing_secret` and the receiver
|
|
404
454
|
verifies two headers:
|
|
405
455
|
- `X-Parse-Webhook-Timestamp` — Unix epoch seconds; requests outside
|
|
@@ -407,6 +457,13 @@ This protects the webhook endpoint against **replayed inbound POSTs** —
|
|
|
407
457
|
- `X-Parse-Webhook-Signature` — hex HMAC-SHA256 of `"#{timestamp}.#{body}"`
|
|
408
458
|
keyed with the signing secret.
|
|
409
459
|
|
|
460
|
+
A signed delivery is deduplicated on its signature, so a captured request
|
|
461
|
+
cannot be replayed inside the skew window, even with an altered or missing
|
|
462
|
+
nonce. To send identical bodies more than once in the same second, include
|
|
463
|
+
an `X-Parse-Webhook-Nonce` header and sign
|
|
464
|
+
`"#{timestamp}.#{nonce}.#{body}"` instead; each delivery then has its own
|
|
465
|
+
signature, and a signature cannot be reused under another nonce.
|
|
466
|
+
|
|
410
467
|
```ruby
|
|
411
468
|
Parse::Webhooks::ReplayProtection.signing_secret = ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
|
|
412
469
|
Parse::Webhooks::ReplayProtection.replay_window_seconds = 120
|