Skip to main content

jmap_server/
lib.rs

1//! Backend-agnostic JMAP server framework (RFC 8620).
2//!
3//! Provides request parsing, ResultReference resolution, HTTP response helpers,
4//! the [`Dispatcher`] machinery, shared backend infrastructure, and generic
5//! JMAP method handlers.
6//!
7//! # Version coupling with `jmap-types` (bd:JMAP-wlip.18)
8//!
9//! This crate re-exports the wire-format types from `jmap-types`
10//! (`Id`, `JmapError`, `JmapRequest`, `JmapResponse`, `Invocation`,
11//! `ResultReference`, `Argument`, `State`, `UTCDate`) plus the marker
12//! traits (`GetObject`, `JmapObject`, `QueryObject`, `SetObject`).
13//! The public API of this crate is therefore *coupled* to
14//! `jmap-types`' public API: any breaking change to a re-exported
15//! type in `jmap-types` is a breaking change here, even when this
16//! crate's own surface is otherwise additive.
17//!
18//! **SemVer pin discipline**: consumers that depend on both
19//! `jmap-server` and `jmap-types` directly MUST pin the `jmap-types`
20//! version to the exact version `jmap-server` resolves to. The
21//! simplest path is to NOT depend on `jmap-types` directly and use
22//! `jmap_server::{Id, JmapError, ...}` instead — re-exports
23//! guarantee consistency. Depending on both with mismatched versions
24//! produces cargo's "expected `jmap_types::Id`, found `jmap_types::Id`"
25//! error (same type name, two different version hashes).
26
27#![forbid(unsafe_code)]
28
29pub use jmap_types::{
30    Argument, Id, Invocation, JmapError, JmapRequest, JmapResponse, ResultReference, State, UTCDate,
31};
32
33pub mod backend;
34pub mod handlers;
35mod helpers;
36
37pub use backend::{
38    AddedItem, BackendChangesError, BackendSetError, ChangesResult, GetObject, JmapBackend,
39    JmapObject, QueryChangesResult, QueryObject, QueryResult, ReservedExtrasKey, SetError,
40    SetErrorType, SetObject, RESERVED_SET_ERROR_WIRE_NAMES,
41};
42pub use handlers::{
43    handle_changes, handle_get, handle_query, handle_query_changes, server_fail_from_backend,
44    server_fail_value_from_backend, SERVER_FAIL_INTERNAL_DESC,
45};
46#[allow(deprecated)]
47pub use helpers::ser;
48pub use helpers::{
49    bool_arg, enforce_max_objects_in_set, extract_account_id, json_merge_patch, not_found_json,
50    now_utc_string, now_utc_string_checked, optional_arg, resolve_query_offset, serialize_value,
51    take_bool_arg, MergePatchError,
52};
53
54mod parse;
55mod response;
56
57pub use parse::{check_known_capabilities, parse_request, resolve_args};
58pub use response::{error_invocation, error_status, request_error, RequestError};
59
60use std::{collections::HashMap, fmt, future::Future, pin::Pin, sync::Arc};
61
62use serde_json::Value;
63use tokio::task;
64
65/// The return type for all [`JmapHandler`] implementations.
66///
67/// Handlers must return a `Send` future.  The concrete type is a heap-allocated
68/// trait object so the trait itself remains object-safe.
69///
70/// The `Vec<Invocation>` holds zero or more additional entries to append to
71/// `methodResponses` immediately after the primary response (in order).  Most
72/// handlers return an empty `Vec`.  RFC 8621 §7.5 `EmailSubmission/set` uses
73/// this to append the implicit `Email/set` invocation for `onSuccessUpdateEmail`.
74pub type HandlerFuture =
75    Pin<Box<dyn Future<Output = Result<(Value, Vec<Invocation>), JmapError>> + Send>>;
76
77/// Implement this for each JMAP method handler.
78///
79/// `CallerCtx` is whatever your auth layer produces — an `Identity`, a session
80/// token, `()`, etc. The dispatcher passes it through unchanged.
81///
82/// # /set response contract
83///
84/// Handlers for `/set` methods (RFC 8620 §5.3) that create objects MUST include
85/// an `"id"` field (type string) in each entry of the `"created"` map.  The
86/// dispatcher reads this field to accumulate `createdIds` in the response.
87/// Entries without an `"id"` field are silently skipped — the dispatcher cannot
88/// retroactively error a method call that already returned success.
89pub trait JmapHandler<CallerCtx>: Send + Sync {
90    /// `method` is the registered method name for this call.  A single handler
91    /// instance may be registered under multiple names (e.g. both `"Foo/get"` and
92    /// `"Bar/get"`); this parameter lets the handler distinguish between them.
93    ///
94    /// `call_id` is the client-supplied identifier for this invocation (RFC 8620 §3.3).
95    /// Handlers may use it for logging or correlation but need not echo it —
96    /// the dispatcher echoes it in the response automatically.
97    ///
98    /// Both parameters are `String` (not `&str`) because the returned future is
99    /// `'static` — it must own all data it captures.  Handlers that do not need
100    /// `method`/`call_id` can ignore them; handlers that do (e.g. echo) simply
101    /// capture the owned value.
102    fn call(
103        &self,
104        method: String,
105        call_id: String,
106        args: Value,
107        caller: CallerCtx,
108    ) -> HandlerFuture;
109}
110
111/// Walk a `/set` handler's primary response and accumulate every
112/// `created[client_id].id` pair into `sink` (RFC 8620 §3.4
113/// `createdIds`) (bd:JMAP-wlip.10).
114///
115/// Lives next to the [`JmapHandler`] doc contract that requires
116/// every entry of `created` to contain a string `"id"` field. Entries
117/// that violate the contract — no `"id"` key, or an `"id"` of a
118/// non-string type — are silently skipped, because the dispatcher
119/// cannot produce a method-level error for a method call that already
120/// succeeded. The shared helper makes the silent-skip behaviour
121/// auditable in one place rather than inlined in the dispatcher loop.
122///
123/// Non-`/set` primary responses (no `"created"` key at the top level,
124/// or `"created"` of a non-Object type) leave `sink` unchanged.
125///
126/// Collision semantics (bd:JMAP-jfia.3): when a creationId in the
127/// `/set` response collides with an entry already in `sink` — whether
128/// from an earlier `/set` call in the same batch OR from the client's
129/// pre-populated `createdIds` map — `HashMap::insert` silently
130/// overwrites: last write wins. The pre-populated collision case is
131/// exercised by
132/// [`tests::created_ids_pre_populated_collision_last_write_wins`].
133/// See the dispatcher call site for the full rationale.
134fn extract_created_ids_into(primary: &Value, sink: &mut HashMap<Id, Id>) {
135    let Some(map) = primary.get("created").and_then(|v| v.as_object()) else {
136        return;
137    };
138    for (client_id, created_obj) in map {
139        if let Some(id_val) = created_obj.get("id").and_then(|v| v.as_str()) {
140            sink.insert(Id::from(client_id.as_str()), Id::from(id_val));
141        }
142    }
143}
144
145/// Dispatches a [`JmapRequest`] to registered method handlers.
146///
147/// Register handlers with [`Dispatcher::register`], then call
148/// [`Dispatcher::dispatch`] per request.  `CallerCtx` is cloned for each
149/// method call in the batch, so it must be `Clone`.
150///
151/// `CallerCtx` must also be `'static` because each handler call is spawned as
152/// a [`tokio::task`].  To share non-static data (e.g. a database connection),
153/// wrap it in `Arc<T>` — `Arc` is `Clone + Send + 'static` when `T: Send + Sync`.
154///
155/// # Thread safety
156///
157/// `Dispatcher` is both `Send` and `Sync`.  Register handlers on one thread,
158/// then wrap in `Arc` and share across tasks — `dispatch` takes `&self` and is
159/// safe to call concurrently.
160pub struct Dispatcher<CallerCtx> {
161    handlers: HashMap<String, Arc<dyn JmapHandler<CallerCtx>>>,
162}
163
164/// Returned by [`Dispatcher::try_register`] when a handler is already
165/// registered under the requested method name.
166///
167/// Added in bd:JMAP-jfia.4 alongside `try_register` to make the
168/// duplicate-registration foot-gun explicit at the call site rather
169/// than silently dropping a binding.
170#[non_exhaustive]
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct DuplicateMethodError {
173    /// The method name that was already registered.
174    pub method: String,
175}
176
177impl std::fmt::Display for DuplicateMethodError {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        write!(f, "handler already registered for method {:?}", self.method)
180    }
181}
182
183impl std::error::Error for DuplicateMethodError {}
184
185impl<CallerCtx: Clone + Send + 'static> Dispatcher<CallerCtx> {
186    /// Create an empty dispatcher with no registered handlers.
187    pub fn new() -> Self {
188        Self {
189            handlers: HashMap::new(),
190        }
191    }
192
193    /// Register a handler for the given method name.
194    ///
195    /// **Registering the same name twice silently replaces the earlier
196    /// handler.** This is a real foot-gun in the workspace pattern where
197    /// each extension crate ships a `register_*_handlers` macro that
198    /// registers ~10 method names against a single `Dispatcher`: a typo
199    /// or accidental double-registration drops one binding with no
200    /// diagnostic, and the handler that "never fires" is hard to debug
201    /// (bd:JMAP-jfia.4). Prefer [`Dispatcher::try_register`] in new
202    /// code; `register` is kept for ergonomic call sites where the
203    /// silent-overwrite is the deliberate choice (e.g. a test fixture
204    /// that overrides a handler for a specific scenario).
205    ///
206    /// Using `Arc` rather than `Box` allows the same handler instance to be
207    /// shared across multiple method name registrations (via `Arc::clone`).
208    pub fn register(
209        &mut self,
210        method: impl Into<String>,
211        handler: Arc<dyn JmapHandler<CallerCtx>>,
212    ) {
213        self.handlers.insert(method.into(), handler);
214    }
215
216    /// Register a handler for the given method name, returning an error
217    /// if the method name is already registered.
218    ///
219    /// This is the recommended registration entry point for production
220    /// code paths (bd:JMAP-jfia.4). Unlike [`Dispatcher::register`], a
221    /// duplicate method name produces
222    /// [`DuplicateMethodError`] rather than silently replacing the
223    /// existing handler, surfacing the collision at the call site that
224    /// caused it.
225    ///
226    /// On `Err(DuplicateMethodError)` the dispatcher's handler map is
227    /// left unchanged — the existing handler is preserved and the
228    /// would-be registration is dropped.
229    ///
230    /// Using `Arc` rather than `Box` allows the same handler instance to be
231    /// shared across multiple method name registrations (via `Arc::clone`).
232    ///
233    /// # Errors
234    ///
235    /// Returns [`DuplicateMethodError`] when `method` is already
236    /// registered.
237    pub fn try_register(
238        &mut self,
239        method: impl Into<String>,
240        handler: Arc<dyn JmapHandler<CallerCtx>>,
241    ) -> Result<(), DuplicateMethodError> {
242        let method = method.into();
243        if self.handlers.contains_key(&method) {
244            return Err(DuplicateMethodError { method });
245        }
246        self.handlers.insert(method, handler);
247        Ok(())
248    }
249
250    /// Process a validated [`JmapRequest`] and return a [`JmapResponse`].
251    ///
252    /// Method calls are processed sequentially per RFC 8620 §3.3.  Each
253    /// handler runs in a `tokio::task::spawn` for panic isolation: a panicking
254    /// handler returns a `serverFail` invocation rather than crashing the
255    /// connection task.
256    ///
257    /// `CallerCtx` must be `Clone + Send + 'static`; see the struct-level doc.
258    ///
259    /// # Runtime requirement (bd:JMAP-jfia.24)
260    ///
261    /// `dispatch` invokes [`tokio::task::spawn`] internally and
262    /// therefore **requires a Tokio runtime in scope at the call
263    /// site**. The async-fn signature itself does not advertise this
264    /// requirement — there is no `?Send` async-trait bound and no
265    /// `Spawner` parameter — but calling `dispatch` without a Tokio
266    /// runtime will panic at the first method call with a
267    /// `there is no reactor running` error.
268    ///
269    /// This is a structural coupling to the Tokio ecosystem.
270    /// Production consumers running under `tokio::main` /
271    /// `Runtime::block_on` already satisfy this; consumers
272    /// experimenting with alternative runtimes (`async-std`,
273    /// `smol`, `embassy`, etc.) cannot use `Dispatcher` without
274    /// either a Tokio compat shim or a custom dispatcher
275    /// re-implementation. Decoupling the spawn mechanism is a
276    /// major-bump API change tracked separately.
277    ///
278    /// # Cancellation
279    ///
280    /// **Per-request cancellation is not supported in the current API**
281    /// (bd:JMAP-wlip.23). If this future is dropped while a handler
282    /// task is running (e.g., the HTTP connection closes), the spawned
283    /// [`tokio::task`] runs to completion — tokio does not cancel
284    /// tasks when their `JoinHandle` is dropped. The handler result
285    /// is discarded.
286    ///
287    /// Production consequence: a JMAP server with a long-running
288    /// backend operation (e.g., `Email/query` over a 10M-mailbox
289    /// account, a slow full-text search, a slow downstream-service
290    /// lookup) cannot react to client disconnect. Every disconnect
291    /// leaks resource consumption equal to the full backend cost.
292    /// Adversarial clients can amplify this into a DoS by opening
293    /// many requests and dropping them.
294    ///
295    /// Recommended mitigations until per-request cancellation is wired:
296    ///
297    /// - **Bound each backend operation's runtime at the backend
298    ///   layer**, e.g. by passing a deadline / timeout from the
299    ///   backend impl's storage client (`tokio::time::timeout` around
300    ///   each database call, RPC deadline, etc.). The handler does
301    ///   not need to know about deadlines; the backend impl does.
302    /// - **Server-wide shutdown** via `tokio::select!` with a
303    ///   broadcast channel from `main` works for the dispatcher loop
304    ///   itself but does NOT propagate into spawned handler tasks.
305    ///   To shut down cleanly, drain the dispatcher first and then
306    ///   let in-flight handler tasks finish.
307    ///
308    /// The "implement cancellation at the handler level" advice
309    /// previously given here was unworkable: the spawned task has
310    /// no access to the outer dispatch-future's context (no token,
311    /// no shared liveness flag), and the [`JmapHandler::call`]
312    /// signature carries no cancellation token.
313    ///
314    /// A future revision may add an opt-in cancellation-token shape
315    /// (CallerCtx-carried token, or a dispatch-time
316    /// `cancel: CancellationToken` parameter that gets signalled on
317    /// future-drop). That is a workspace-architectural decision —
318    /// adding `tokio_util` to the dep allowlist plus threading the
319    /// token through every backend trait method — and is tracked
320    /// separately.
321    pub async fn dispatch(
322        &self,
323        request: JmapRequest,
324        caller: CallerCtx,
325        session_state: State,
326    ) -> JmapResponse {
327        let mut method_responses: Vec<Invocation> = Vec::with_capacity(request.method_calls.len());
328        let client_sent_created_ids = request.created_ids.is_some();
329        let mut created_ids: HashMap<Id, Id> = request.created_ids.unwrap_or_default();
330
331        // Invocation layout: (method_name, args, call_id) — RFC 8620 §3.3.
332        for (method, mut args, call_id) in request.method_calls {
333            // Resolve ResultReferences from prior responses.
334            if let Err(e) = resolve_args(&mut args, &method_responses) {
335                method_responses.push(error_invocation(&call_id, e));
336                continue;
337            }
338
339            // Look up the handler.
340            let Some(handler) = self.handlers.get(&method).map(Arc::clone) else {
341                method_responses.push(error_invocation(&call_id, JmapError::unknown_method()));
342                continue;
343            };
344
345            let caller_clone = caller.clone();
346            let method_clone = method.clone();
347            let call_id_clone = call_id.clone();
348
349            // Run in a spawned task for panic isolation.
350            let result: Result<
351                Result<(Value, Vec<Invocation>), JmapError>,
352                tokio::task::JoinError,
353            > = task::spawn(async move {
354                handler
355                    .call(method_clone, call_id_clone, args, caller_clone)
356                    .await
357            })
358            .await;
359
360            match result {
361                Ok(Ok((primary_value, extra_invocations))) => {
362                    // Accumulate createdIds from /set responses (RFC 8620 §3.4).
363                    // Only when the client sent createdIds; otherwise the field
364                    // is omitted from the response.
365                    //
366                    // Duplicate-creationId behaviour (bd:JMAP-wlip.7,
367                    // bd:JMAP-jfia.3): HashMap::insert silently overwrites
368                    // on duplicate key. Two flavours of duplicate:
369                    //
370                    //   (a) Intra-batch: a client reuses the same
371                    //   creationId across two /set calls in the same
372                    //   batch (e.g. "c1" in both Mailbox/set and
373                    //   Email/set). The second mapping wins and the
374                    //   first is lost.
375                    //
376                    //   (b) Pre-populated collision: a client
377                    //   pre-populates createdIds with X->A and a /set
378                    //   call in the same batch returns X->B (B != A).
379                    //   The /set value wins and the pre-populated A is
380                    //   lost. This is reachable via long-lived
381                    //   background tasks replaying a queued request
382                    //   whose creationIds overlap with the current
383                    //   session's batch.
384                    //
385                    // RFC 8620 §3.4 does not explicitly require either
386                    // last-write-wins or rejection; the convention here
387                    // is last-write-wins because (a) the response order
388                    // is deterministic so the behaviour is at least
389                    // reproducible, (b) detecting either flavour of
390                    // duplicate would require either a per-batch
391                    // creationId pre-check (adds a HashSet allocation
392                    // per request) or a second pass over
393                    // method_responses after dispatch, and (c) this
394                    // crate is the canonical foundation for every
395                    // *-server extension — a wire-behaviour change
396                    // (e.g. reject-on-collision) would ripple to every
397                    // downstream consumer and is out of scope for a
398                    // foundation crate without an RFC mandate.
399                    //
400                    // Clients SHOULD generate unique creationIds across
401                    // a batch and SHOULD NOT pre-populate creationIds
402                    // that any /set call in the same batch will
403                    // produce. Both collision flavours are exercised
404                    // explicitly by
405                    // [`tests::created_ids_intra_batch_collision_last_write_wins`]
406                    // and
407                    // [`tests::created_ids_pre_populated_collision_last_write_wins`]
408                    // so a future refactor that flips the order is
409                    // caught.
410                    if client_sent_created_ids {
411                        extract_created_ids_into(&primary_value, &mut created_ids);
412                    }
413                    // Push the primary response first, then any extra invocations
414                    // appended by the handler (e.g. onSuccessUpdateEmail from
415                    // EmailSubmission/set, RFC 8621 §7.5).  Order is preserved.
416                    method_responses.push((method, primary_value, call_id));
417                    method_responses.extend(extra_invocations);
418                }
419                Ok(Err(e)) => {
420                    method_responses.push(error_invocation(&call_id, e));
421                }
422                Err(join_err) => {
423                    // Panics and cancellations both map to serverFail, but with
424                    // distinct descriptions to aid server-side diagnostics.
425                    let desc = if join_err.is_cancelled() {
426                        "task cancelled"
427                    } else {
428                        "internal error"
429                    };
430                    method_responses.push(error_invocation(&call_id, JmapError::server_fail(desc)));
431                }
432            }
433        }
434
435        let created_ids = client_sent_created_ids.then_some(created_ids);
436
437        JmapResponse::new(method_responses, session_state, created_ids)
438    }
439}
440
441impl<CallerCtx: Clone + Send + 'static> Default for Dispatcher<CallerCtx> {
442    fn default() -> Self {
443        Self::new()
444    }
445}
446
447impl<CallerCtx> fmt::Debug for Dispatcher<CallerCtx> {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        f.debug_struct("Dispatcher")
450            .field("methods", &self.handlers.keys())
451            .finish()
452    }
453}
454
455// ---------------------------------------------------------------------------
456// ClosureHandler — generic backend-wrapping JmapHandler that forwards CallerCtx
457// ---------------------------------------------------------------------------
458
459/// Type alias for the closure stored inside [`ClosureHandler`].
460///
461/// The `String` argument is the `call_id` (the client-supplied correlation
462/// identifier from RFC 8620 §3.3), not the method name.  If you need the
463/// method name inside the closure, register the handler with
464/// [`Dispatcher::register`] and use [`JmapHandler`] directly instead.
465///
466/// `C` is the caller context (e.g. an auth identity) forwarded from
467/// [`Dispatcher::dispatch`]. Closures that don't need it can ignore the
468/// argument with `_ctx`.
469pub type BackendCallFn<B, C> =
470    dyn Fn(Arc<B>, String, serde_json::Value, C) -> HandlerFuture + Send + Sync + 'static;
471
472/// A [`JmapHandler`] that wraps an async closure over a shared backend and
473/// forwards `CallerCtx` to it.
474///
475/// Use this when your handler closures need per-request context — for
476/// example, an auth identity that controls which data the handler can
477/// access. Closures that don't need the context can simply ignore the
478/// `ctx` parameter.
479///
480/// # Usage
481///
482/// ```rust,ignore
483/// use jmap_server::{ClosureHandler, Dispatcher};
484/// use std::sync::Arc;
485///
486/// #[derive(Clone)]
487/// struct AuthCtx { user_id: String }
488///
489/// let handler: Arc<ClosureHandler<MyBackend, AuthCtx>> =
490///     Arc::new(ClosureHandler::new(
491///         Arc::new(my_backend),
492///         |b, call_id, args, ctx| {
493///             Box::pin(async move {
494///                 // ctx.user_id is available here
495///                 handle_something(&*b, args, &ctx.user_id).await
496///             })
497///         },
498///     ));
499///
500/// let mut dispatcher: Dispatcher<AuthCtx> = Dispatcher::new();
501/// dispatcher.register("MyMethod/get", handler);
502/// ```
503/// A [`JmapHandler`] handle wrapping a shared backend + an async
504/// closure. Construct via [`ClosureHandler::new`] — the fields are
505/// crate-private (bd:JMAP-jfia.5) to keep the handle opaque, prevent
506/// post-construction hot-swap of the closure or backend, and let the
507/// constructor remain the sole site that enforces invariants when
508/// future fields (per-handler tracing context, metrics handle, etc.)
509/// are added.
510#[non_exhaustive]
511pub struct ClosureHandler<B: Send + Sync + 'static, C: Clone + Send + 'static> {
512    /// Shared reference to the backend implementation, passed to the
513    /// closure on every method call. Crate-private to keep
514    /// `ClosureHandler` an opaque handle (bd:JMAP-jfia.5).
515    pub(crate) backend: Arc<B>,
516    /// The async closure invoked for each JMAP method call this handler
517    /// receives from the dispatcher. Crate-private to keep
518    /// `ClosureHandler` an opaque handle (bd:JMAP-jfia.5).
519    pub(crate) call_fn: Box<BackendCallFn<B, C>>,
520}
521
522impl<B: Send + Sync + 'static, C: Clone + Send + 'static> ClosureHandler<B, C> {
523    /// Construct a [`ClosureHandler`] wrapping a shared backend and an
524    /// async closure (bd:JMAP-wlip.17).
525    ///
526    /// This is the supported construction path. The struct is
527    /// `#[non_exhaustive]` so future fields (per-handler tracing
528    /// context, metrics handle, timeout, etc.) can be added without a
529    /// major-version bump — external callers MUST go through `new`
530    /// rather than struct-literal syntax.
531    ///
532    /// The `call_fn` parameter is generic over
533    /// `F: Fn(...) + Send + Sync + 'static` (bd:JMAP-jfia.40), so
534    /// callers can pass a closure directly without wrapping it in
535    /// `Box::new`. Existing callers that already wrap in
536    /// `Box::new(...)` continue to compile unchanged:
537    /// `Box<dyn Fn(...) + Send + Sync + 'static>` itself implements
538    /// `Fn(...)` via the blanket
539    /// `impl<F: Fn(...)> Fn(...) for Box<F>`, so the boxed form
540    /// satisfies the generic bound. Internally, the closure is boxed
541    /// once at construction and stored as
542    /// `Box<BackendCallFn<B, C>>`.
543    pub fn new<F>(backend: Arc<B>, call_fn: F) -> Self
544    where
545        F: Fn(Arc<B>, String, serde_json::Value, C) -> HandlerFuture + Send + Sync + 'static,
546    {
547        Self {
548            backend,
549            call_fn: Box::new(call_fn),
550        }
551    }
552}
553
554impl<B: Send + Sync + 'static, C: Clone + Send + 'static> JmapHandler<C> for ClosureHandler<B, C> {
555    fn call(
556        &self,
557        _method: String,
558        call_id: String,
559        args: serde_json::Value,
560        caller: C,
561    ) -> HandlerFuture {
562        (self.call_fn)(Arc::clone(&self.backend), call_id, args, caller)
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use serde_json::{json, Value};
570    use std::sync::{Arc, Mutex};
571
572    // Compile-time: Dispatcher must be Send + Sync so it can be wrapped in Arc
573    // and shared across tokio tasks.  This assertion catches future regressions
574    // that would silently break thread-safety (e.g., adding a Cell or Rc field).
575    #[allow(dead_code)]
576    fn assert_dispatcher_send_sync() {
577        fn check<T: Send + Sync>() {}
578        check::<Dispatcher<String>>();
579        check::<Dispatcher<()>>();
580    }
581
582    // -----------------------------------------------------------------------
583    // Test handler implementations
584    // -----------------------------------------------------------------------
585
586    /// Returns a fixed Value regardless of inputs.
587    struct EchoHandler(Value);
588
589    impl<C: Clone + Send + 'static> JmapHandler<C> for EchoHandler {
590        fn call(
591            &self,
592            _method: String,
593            _call_id: String,
594            _args: Value,
595            _caller: C,
596        ) -> HandlerFuture {
597            let v = self.0.clone();
598            Box::pin(async move { Ok((v, vec![])) })
599        }
600    }
601
602    /// Returns a fixed error.
603    struct ErrorHandler(JmapError);
604
605    impl JmapHandler<String> for ErrorHandler {
606        fn call(
607            &self,
608            _method: String,
609            _call_id: String,
610            _args: Value,
611            _caller: String,
612        ) -> HandlerFuture {
613            let e = self.0.clone();
614            Box::pin(async move { Err(e) })
615        }
616    }
617
618    /// Captures the resolved args it was called with.
619    struct CaptureArgsHandler(Arc<Mutex<Option<Value>>>);
620
621    impl JmapHandler<String> for CaptureArgsHandler {
622        fn call(
623            &self,
624            _method: String,
625            _call_id: String,
626            args: Value,
627            _caller: String,
628        ) -> HandlerFuture {
629            let slot = self.0.clone();
630            Box::pin(async move {
631                *slot.lock().expect("test: mutex poisoned") = Some(args);
632                Ok((json!({}), vec![]))
633            })
634        }
635    }
636
637    /// Captures the caller value it was called with.
638    struct CaptureCallerHandler(Arc<Mutex<Option<String>>>);
639
640    impl JmapHandler<String> for CaptureCallerHandler {
641        fn call(
642            &self,
643            _method: String,
644            _call_id: String,
645            _args: Value,
646            caller: String,
647        ) -> HandlerFuture {
648            let slot = self.0.clone();
649            Box::pin(async move {
650                *slot.lock().expect("test: mutex poisoned") = Some(caller);
651                Ok((json!({}), vec![]))
652            })
653        }
654    }
655
656    /// Panics unconditionally.
657    struct PanicHandler;
658
659    impl JmapHandler<String> for PanicHandler {
660        fn call(
661            &self,
662            _method: String,
663            _call_id: String,
664            _args: Value,
665            _caller: String,
666        ) -> HandlerFuture {
667            Box::pin(async move { panic!("deliberate test panic") })
668        }
669    }
670
671    // -----------------------------------------------------------------------
672    // Helper: build a minimal JmapRequest with a single method call.
673    // -----------------------------------------------------------------------
674
675    fn single_call(method: &str, args: Value, call_id: &str) -> JmapRequest {
676        JmapRequest::new(
677            vec!["urn:ietf:params:jmap:core".into()],
678            vec![(method.into(), args, call_id.into())],
679            None,
680        )
681    }
682
683    // -----------------------------------------------------------------------
684    // Basic dispatch
685    // -----------------------------------------------------------------------
686
687    /// Oracle: RFC 8620 §7.1 — unknownMethod when no handler is registered.
688    #[tokio::test]
689    async fn unknown_method_returns_error_invocation() {
690        let d: Dispatcher<String> = Dispatcher::new();
691        let req = single_call("Foo/get", json!({}), "c0");
692        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
693        assert_eq!(resp.method_responses.len(), 1);
694        let (_, args, call_id) = &resp.method_responses[0];
695        assert_eq!(call_id, "c0");
696        assert_eq!(args["type"], "unknownMethod");
697    }
698
699    /// Oracle: RFC 8620 §3.5 — successful call appears in methodResponses.
700    #[tokio::test]
701    async fn known_method_success() {
702        let mut d: Dispatcher<String> = Dispatcher::new();
703        d.register("Foo/get", Arc::new(EchoHandler(json!({"list": []}))));
704        let req = single_call("Foo/get", json!({}), "c1");
705        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
706        assert_eq!(resp.method_responses.len(), 1);
707        let (method, args, call_id) = &resp.method_responses[0];
708        assert_eq!(method, "Foo/get");
709        assert_eq!(call_id, "c1");
710        assert_eq!(args["list"], json!([]));
711    }
712
713    /// Oracle: RFC 8620 §3.6.2 — method-level errors appear in methodResponses.
714    #[tokio::test]
715    async fn handler_returns_error() {
716        let mut d: Dispatcher<String> = Dispatcher::new();
717        d.register("Foo/get", Arc::new(ErrorHandler(JmapError::not_found())));
718        let req = single_call("Foo/get", json!({}), "c2");
719        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
720        assert_eq!(resp.method_responses.len(), 1);
721        let (_, args, _) = &resp.method_responses[0];
722        assert_eq!(args["type"], "notFound");
723    }
724
725    /// Oracle (bd:JMAP-jfia.4): try_register MUST return Ok for the
726    /// first registration of a method name, and Err(DuplicateMethodError)
727    /// for any subsequent registration of the same name. On Err the
728    /// dispatcher's handler map MUST be left unchanged — the
729    /// already-registered handler stays in place.
730    #[tokio::test]
731    async fn try_register_succeeds_then_errors_on_duplicate() {
732        let mut d: Dispatcher<String> = Dispatcher::new();
733        let first = Arc::new(EchoHandler(json!({"v": "first"})));
734        let second = Arc::new(EchoHandler(json!({"v": "second"})));
735
736        d.try_register("Foo/get", first)
737            .expect("first registration must succeed");
738
739        let err = d
740            .try_register("Foo/get", second)
741            .expect_err("second registration must error");
742        assert_eq!(err.method, "Foo/get");
743        assert_eq!(
744            err.to_string(),
745            "handler already registered for method \"Foo/get\""
746        );
747
748        // The first handler MUST still be the one that fires — try_register
749        // must NOT have replaced it as a side-effect of the error path.
750        let req = single_call("Foo/get", json!({}), "c0");
751        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
752        let (_, args, _) = &resp.method_responses[0];
753        assert_eq!(
754            args["v"], "first",
755            "try_register error path must not replace the existing handler"
756        );
757    }
758
759    /// Oracle (bd:JMAP-jfia.4): register (the silent-overwrite variant)
760    /// MUST continue to replace on duplicate, since established consumers
761    /// depend on that ergonomic for test fixtures. Pins the contract so
762    /// a future refactor that "fixes" register's silent overwrite is
763    /// caught.
764    #[tokio::test]
765    async fn register_silently_overwrites_on_duplicate() {
766        let mut d: Dispatcher<String> = Dispatcher::new();
767        d.register("Foo/get", Arc::new(EchoHandler(json!({"v": "first"}))));
768        d.register("Foo/get", Arc::new(EchoHandler(json!({"v": "second"}))));
769
770        let req = single_call("Foo/get", json!({}), "c0");
771        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
772        let (_, args, _) = &resp.method_responses[0];
773        assert_eq!(args["v"], "second", "register must replace on duplicate");
774    }
775
776    /// Oracle: RFC 8620 §3.4 — sessionState in response matches what dispatcher was given.
777    #[tokio::test]
778    async fn session_state_echoed() {
779        let d: Dispatcher<String> = Dispatcher::new();
780        let req = JmapRequest::new(vec!["urn:ietf:params:jmap:core".into()], vec![], None);
781        let resp = d.dispatch(req, "alice".into(), "my-state-123".into()).await;
782        assert_eq!(resp.session_state.as_ref(), "my-state-123");
783    }
784
785    // -----------------------------------------------------------------------
786    // Batch
787    // -----------------------------------------------------------------------
788
789    /// Oracle: RFC 8620 §3.3 — methodCalls processed in order, all responses present.
790    /// Also covers: error in one method does not abort the batch (RFC 8620 §3.6.2).
791    #[tokio::test]
792    async fn mixed_batch_all_responses_in_order() {
793        let mut d: Dispatcher<String> = Dispatcher::new();
794        d.register("M/a", Arc::new(EchoHandler(json!({"ok": true}))));
795        // "M/b" is NOT registered → unknownMethod
796        let req = JmapRequest::new(
797            vec!["urn:ietf:params:jmap:core".into()],
798            vec![
799                ("M/a".into(), json!({}), "c0".into()),
800                ("M/b".into(), json!({}), "c1".into()),
801                ("M/a".into(), json!({}), "c2".into()),
802            ],
803            None,
804        );
805        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
806        assert_eq!(
807            resp.method_responses.len(),
808            3,
809            "all three calls must produce a response"
810        );
811        // responses[0]: M/a success
812        assert_eq!(resp.method_responses[0].2, "c0");
813        assert!(
814            resp.method_responses[0].1.get("type").is_none(),
815            "c0 must not be an error"
816        );
817        // responses[1]: M/b unknownMethod
818        assert_eq!(resp.method_responses[1].2, "c1");
819        assert_eq!(resp.method_responses[1].1["type"], "unknownMethod");
820        // responses[2]: M/a success (error in [1] did not abort the batch)
821        assert_eq!(resp.method_responses[2].2, "c2");
822        assert!(
823            resp.method_responses[2].1.get("type").is_none(),
824            "c2 must not be an error"
825        );
826    }
827
828    /// Oracle: RFC 8620 §3.6.2 — error in one method does not abort subsequent calls.
829    #[tokio::test]
830    async fn error_does_not_abort_subsequent_calls() {
831        let mut d: Dispatcher<String> = Dispatcher::new();
832        d.register("M/ok", Arc::new(EchoHandler(json!({"ok": true}))));
833        d.register("M/err", Arc::new(ErrorHandler(JmapError::forbidden())));
834        let req = JmapRequest::new(
835            vec!["urn:ietf:params:jmap:core".into()],
836            vec![
837                ("M/err".into(), json!({}), "c0".into()),
838                ("M/ok".into(), json!({}), "c1".into()),
839            ],
840            None,
841        );
842        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
843        assert_eq!(resp.method_responses.len(), 2);
844        assert_eq!(resp.method_responses[0].1["type"], "forbidden");
845        assert!(
846            resp.method_responses[1].1.get("type").is_none(),
847            "second call must succeed"
848        );
849    }
850
851    // -----------------------------------------------------------------------
852    // Panic isolation
853    // -----------------------------------------------------------------------
854
855    /// Oracle: RFC 8620 §7.1 serverFail; PLAN.md panic isolation design decision.
856    #[tokio::test]
857    async fn panicking_handler_returns_server_fail() {
858        let mut d: Dispatcher<String> = Dispatcher::new();
859        d.register("Panic/now", Arc::new(PanicHandler));
860        let req = single_call("Panic/now", json!({}), "c0");
861        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
862        assert_eq!(resp.method_responses.len(), 1);
863        let (_, args, _) = &resp.method_responses[0];
864        assert_eq!(
865            args["type"], "serverFail",
866            "panicking handler must produce serverFail"
867        );
868    }
869
870    /// Oracle (bd:JMAP-wlip.12): security invariant — panic payloads
871    /// may contain secrets and MUST NOT leak through ANY field of the
872    /// error invocation, not just `description`. A future refactor that
873    /// surfaces panic-payload text through a typed `context`, an
874    /// `innerError` nested object, or any other field would slip past a
875    /// single-field check.
876    ///
877    /// The assertion walks the entire methodResponse args Value
878    /// recursively and asserts that no string anywhere in the tree
879    /// contains the canary `"deliberate test panic"` from
880    /// [`PanicHandler`].
881    ///
882    /// **Decision record (bd:JMAP-jfia.14)**: a future "simplify" pass
883    /// will reasonably suggest narrowing this to a single-field check
884    /// against `args["description"]` because that is where panic
885    /// payloads land today. That suggestion is **WRONG** and must be
886    /// rejected: a single-field check encodes the current
887    /// implementation rather than the security invariant. The
888    /// recursive walk encodes the actual invariant ("panic-payload
889    /// text does not leak to the wire, ANYWHERE in the response shape")
890    /// and survives refactors of the error shape. Defending this
891    /// shape protects the workspace credential/PII redaction policy
892    /// from drift.
893    #[tokio::test]
894    async fn panic_message_not_in_response() {
895        /// Returns `true` iff any `Value::String` in the tree
896        /// contains `needle`.
897        fn value_contains_recursive(v: &Value, needle: &str) -> bool {
898            match v {
899                Value::String(s) => s.contains(needle),
900                Value::Array(arr) => arr.iter().any(|x| value_contains_recursive(x, needle)),
901                Value::Object(o) => o.values().any(|x| value_contains_recursive(x, needle)),
902                Value::Null | Value::Bool(_) | Value::Number(_) => false,
903            }
904        }
905
906        let mut d: Dispatcher<String> = Dispatcher::new();
907        d.register("Panic/now", Arc::new(PanicHandler));
908        let req = single_call("Panic/now", json!({}), "c0");
909        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
910        let (_, args, _) = &resp.method_responses[0];
911        assert!(
912            !value_contains_recursive(args, "deliberate test panic"),
913            "panic message must not leak into ANY field of the response: {args}"
914        );
915    }
916
917    // -----------------------------------------------------------------------
918    // ResultReference end-to-end
919    // -----------------------------------------------------------------------
920
921    /// Oracle: RFC 8620 §3.7 — #-prefixed args resolved from prior responses before handler call.
922    #[tokio::test]
923    async fn result_reference_resolved_before_dispatch() {
924        let captured = Arc::new(Mutex::new(None::<Value>));
925        let mut d: Dispatcher<String> = Dispatcher::new();
926        d.register(
927            "Foo/get",
928            Arc::new(EchoHandler(json!({"list": [{"id": "item-1"}]}))),
929        );
930        d.register(
931            "Bar/query",
932            Arc::new(CaptureArgsHandler(Arc::clone(&captured))),
933        );
934        let req = JmapRequest::new(
935            vec!["urn:ietf:params:jmap:core".into()],
936            vec![
937                ("Foo/get".into(), json!({}), "c0".into()),
938                (
939                    "Bar/query".into(),
940                    json!({"#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/list/0/id"}}),
941                    "c1".into(),
942                ),
943            ],
944            None,
945        );
946        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
947        assert_eq!(resp.method_responses.len(), 2);
948        // c1 must succeed, not be an error
949        assert!(
950            resp.method_responses[1].1.get("type").is_none(),
951            "Bar/query must succeed after ResultReference resolution"
952        );
953        // Handler must have received the resolved value, not the original #ids object
954        let got = captured
955            .lock()
956            .unwrap()
957            .clone()
958            .expect("CaptureArgsHandler was not called");
959        assert_eq!(
960            got["ids"],
961            json!("item-1"),
962            "resolved value must be the string item-1"
963        );
964        assert!(
965            got.get("#ids").is_none(),
966            "#ids key must have been replaced"
967        );
968    }
969
970    /// Oracle: RFC 8620 §3.7 — resolution failure → error for that call, batch continues.
971    #[tokio::test]
972    async fn result_reference_failure_stops_that_call() {
973        let d: Dispatcher<String> = Dispatcher::new();
974        let req = single_call(
975            "Foo/get",
976            json!({"#ids": {"resultOf": "nonexistent", "name": "Foo/get", "path": "/x"}}),
977            "c0",
978        );
979        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
980        assert_eq!(resp.method_responses.len(), 1);
981        let (_, args, _) = &resp.method_responses[0];
982        assert!(
983            args.get("type").is_some(),
984            "failed ResultReference must produce an error invocation"
985        );
986    }
987
988    // -----------------------------------------------------------------------
989    // createdIds
990    // -----------------------------------------------------------------------
991
992    /// Oracle: RFC 8620 §3.3 createdIds — server-assigned IDs returned from /set
993    /// responses are accumulated into resp.created_ids when client sent createdIds.
994    #[tokio::test]
995    async fn created_ids_accumulated_from_set_response() {
996        let mut d: Dispatcher<String> = Dispatcher::new();
997        d.register(
998            "Foo/set",
999            Arc::new(EchoHandler(
1000                json!({"created": {"client-1": {"id": "server-abc"}}}),
1001            )),
1002        );
1003        // Client sends createdIds (empty map) to signal it wants the response field.
1004        let req = JmapRequest::new(
1005            vec!["urn:ietf:params:jmap:core".into()],
1006            vec![("Foo/set".into(), json!({}), "c0".into())],
1007            Some(std::collections::HashMap::new()),
1008        );
1009        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1010        let ids = resp
1011            .created_ids
1012            .as_ref()
1013            .expect("created_ids must be Some when client sent createdIds");
1014        assert_eq!(
1015            ids.get(&Id::from("client-1")),
1016            Some(&Id::from("server-abc")),
1017            "client-1 must map to server-abc"
1018        );
1019    }
1020
1021    /// Oracle: RFC 8620 §3.4 — createdIds omitted when no objects were created.
1022    #[tokio::test]
1023    async fn created_ids_absent_when_no_set() {
1024        let mut d: Dispatcher<String> = Dispatcher::new();
1025        d.register("Foo/get", Arc::new(EchoHandler(json!({"list": []}))));
1026        let req = single_call("Foo/get", json!({}), "c0");
1027        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1028        assert!(
1029            resp.created_ids.is_none(),
1030            "created_ids must be None when no /set call created objects"
1031        );
1032    }
1033
1034    /// Oracle: RFC 8620 §3.3 — createdIds accumulates across ALL /set calls in the batch.
1035    #[tokio::test]
1036    async fn created_ids_accumulated_across_multiple_set_calls() {
1037        let mut d: Dispatcher<String> = Dispatcher::new();
1038        d.register(
1039            "A/set",
1040            Arc::new(EchoHandler(json!({"created": {"cA": {"id": "sA"}}}))),
1041        );
1042        d.register(
1043            "B/set",
1044            Arc::new(EchoHandler(json!({"created": {"cB": {"id": "sB"}}}))),
1045        );
1046        // Client sends createdIds to signal it wants the response field.
1047        let req = JmapRequest::new(
1048            vec!["urn:ietf:params:jmap:core".into()],
1049            vec![
1050                ("A/set".into(), json!({}), "c0".into()),
1051                ("B/set".into(), json!({}), "c1".into()),
1052            ],
1053            Some(std::collections::HashMap::new()),
1054        );
1055        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1056        let ids = resp
1057            .created_ids
1058            .as_ref()
1059            .expect("created_ids must be Some when client sent createdIds");
1060        assert_eq!(
1061            ids.get(&Id::from("cA")),
1062            Some(&Id::from("sA")),
1063            "cA must be present"
1064        );
1065        assert_eq!(
1066            ids.get(&Id::from("cB")),
1067            Some(&Id::from("sB")),
1068            "cB must be present"
1069        );
1070    }
1071
1072    /// Oracle: RFC 8620 §3.4 — pre-populated client createdIds are preserved and
1073    /// new /set entries are merged in alongside them.
1074    #[tokio::test]
1075    async fn created_ids_merges_with_pre_populated_map() {
1076        let mut d: Dispatcher<String> = Dispatcher::new();
1077        d.register(
1078            "Foo/set",
1079            Arc::new(EchoHandler(
1080                json!({"created": {"client-new": {"id": "server-new"}}}),
1081            )),
1082        );
1083        // Client sends a pre-populated createdIds map.
1084        let mut initial = std::collections::HashMap::new();
1085        initial.insert(Id::from("client-old"), Id::from("server-old"));
1086        let req = JmapRequest::new(
1087            vec!["urn:ietf:params:jmap:core".into()],
1088            vec![("Foo/set".into(), json!({}), "c0".into())],
1089            Some(initial),
1090        );
1091        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1092        let ids = resp
1093            .created_ids
1094            .as_ref()
1095            .expect("created_ids must be Some when client sent createdIds");
1096        assert_eq!(
1097            ids.get(&Id::from("client-old")),
1098            Some(&Id::from("server-old")),
1099            "pre-populated entry must be preserved"
1100        );
1101        assert_eq!(
1102            ids.get(&Id::from("client-new")),
1103            Some(&Id::from("server-new")),
1104            "new /set entry must be merged in"
1105        );
1106    }
1107
1108    /// Oracle (bd:JMAP-jfia.3): when the client pre-populates
1109    /// `createdIds` with `X -> A` and a `/set` call in the same batch
1110    /// returns `X -> B` (`B != A`), the dispatcher applies last-write-
1111    /// wins semantics: the response carries `X -> B`, and the
1112    /// pre-populated `A` is dropped. This is surprising and the spec is
1113    /// ambiguous on which semantics is correct (RFC 8620 §3.4), but
1114    /// matches the intra-batch duplicate convention documented at the
1115    /// dispatch call site and avoids a wire-behaviour change in the
1116    /// canonical foundation crate. The test exists to catch a future
1117    /// refactor that silently flips the order to first-write-wins.
1118    #[tokio::test]
1119    async fn created_ids_pre_populated_collision_last_write_wins() {
1120        let mut d: Dispatcher<String> = Dispatcher::new();
1121        d.register(
1122            "Foo/set",
1123            Arc::new(EchoHandler(
1124                json!({"created": {"client-X": {"id": "server-B"}}}),
1125            )),
1126        );
1127        // Client pre-populates client-X -> server-A.
1128        let mut initial = std::collections::HashMap::new();
1129        initial.insert(Id::from("client-X"), Id::from("server-A"));
1130        let req = JmapRequest::new(
1131            vec!["urn:ietf:params:jmap:core".into()],
1132            vec![("Foo/set".into(), json!({}), "c0".into())],
1133            Some(initial),
1134        );
1135        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1136        let ids = resp
1137            .created_ids
1138            .as_ref()
1139            .expect("created_ids must be Some when client sent createdIds");
1140        assert_eq!(
1141            ids.get(&Id::from("client-X")),
1142            Some(&Id::from("server-B")),
1143            "last-write-wins: /set response overrides pre-populated entry"
1144        );
1145        assert_eq!(
1146            ids.len(),
1147            1,
1148            "no extra entries should appear from the collision"
1149        );
1150    }
1151
1152    /// Oracle (bd:JMAP-jfia.3): when two `/set` calls in the same batch
1153    /// report the same creationId with different values, the
1154    /// dispatcher applies last-write-wins: the second `/set` response's
1155    /// mapping is the one preserved in the final `createdIds` map. The
1156    /// existing dispatch call-site comment documents this convention
1157    /// (bd:JMAP-wlip.7); the test pins it.
1158    #[tokio::test]
1159    async fn created_ids_intra_batch_collision_last_write_wins() {
1160        let mut d: Dispatcher<String> = Dispatcher::new();
1161        d.register(
1162            "A/set",
1163            Arc::new(EchoHandler(json!({"created": {"cX": {"id": "sA"}}}))),
1164        );
1165        d.register(
1166            "B/set",
1167            Arc::new(EchoHandler(json!({"created": {"cX": {"id": "sB"}}}))),
1168        );
1169        let req = JmapRequest::new(
1170            vec!["urn:ietf:params:jmap:core".into()],
1171            vec![
1172                ("A/set".into(), json!({}), "c0".into()),
1173                ("B/set".into(), json!({}), "c1".into()),
1174            ],
1175            Some(std::collections::HashMap::new()),
1176        );
1177        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1178        let ids = resp
1179            .created_ids
1180            .as_ref()
1181            .expect("created_ids must be Some when client sent createdIds");
1182        assert_eq!(
1183            ids.get(&Id::from("cX")),
1184            Some(&Id::from("sB")),
1185            "last-write-wins: second /set call's mapping for cX preserved"
1186        );
1187        assert_eq!(
1188            ids.len(),
1189            1,
1190            "no extra entries should appear from the collision"
1191        );
1192    }
1193
1194    // -----------------------------------------------------------------------
1195    // CallerCtx
1196    // -----------------------------------------------------------------------
1197
1198    /// Oracle: PLAN.md CallerCtx design — caller value passed through to handler unchanged.
1199    #[tokio::test]
1200    async fn caller_ctx_passed_to_handler() {
1201        let captured = Arc::new(Mutex::new(None::<String>));
1202        let mut d: Dispatcher<String> = Dispatcher::new();
1203        d.register(
1204            "Foo/get",
1205            Arc::new(CaptureCallerHandler(Arc::clone(&captured))),
1206        );
1207        let req = single_call("Foo/get", json!({}), "c0");
1208        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1209        assert!(
1210            resp.method_responses[0].1.get("type").is_none(),
1211            "must succeed"
1212        );
1213        let got = captured
1214            .lock()
1215            .unwrap()
1216            .clone()
1217            .expect("handler was not called");
1218        assert_eq!(got, "alice", "caller must be passed through unchanged");
1219    }
1220
1221    /// Oracle: PLAN.md — CallerCtx = () must work (unit type as auth context).
1222    #[tokio::test]
1223    async fn unit_caller_ctx_works() {
1224        let mut d: Dispatcher<()> = Dispatcher::new();
1225        d.register("Foo/get", Arc::new(EchoHandler(json!({"ok": true}))));
1226        let req = single_call("Foo/get", json!({}), "c0");
1227        let resp = d.dispatch(req, (), "s0".into()).await;
1228        assert_eq!(resp.method_responses.len(), 1);
1229        assert!(
1230            resp.method_responses[0].1.get("type").is_none(),
1231            "must succeed with () caller"
1232        );
1233    }
1234
1235    // -----------------------------------------------------------------------
1236    // Extra invocations
1237    // -----------------------------------------------------------------------
1238
1239    /// A handler that returns both a primary response and one extra invocation.
1240    ///
1241    /// Models RFC 8621 §7.5 EmailSubmission/set with onSuccessUpdateEmail: the
1242    /// submission response is primary; the implied Email/set call is extra.
1243    struct ExtraInvocationHandler;
1244
1245    impl JmapHandler<String> for ExtraInvocationHandler {
1246        fn call(
1247            &self,
1248            _method: String,
1249            _call_id: String,
1250            _args: Value,
1251            _caller: String,
1252        ) -> HandlerFuture {
1253            Box::pin(async move {
1254                let primary = json!({"type": "primary"});
1255                let extra: Vec<Invocation> = vec![(
1256                    "Extra/call".to_owned(),
1257                    json!({"type": "extra"}),
1258                    "x0".to_owned(),
1259                )];
1260                Ok((primary, extra))
1261            })
1262        }
1263    }
1264
1265    /// Oracle: handler returning extra invocations → both primary and extra appear in
1266    /// methodResponses in order (primary first, then extra).
1267    #[tokio::test]
1268    async fn extra_invocations_appended_after_primary() {
1269        let mut d: Dispatcher<String> = Dispatcher::new();
1270        d.register("Sub/set", Arc::new(ExtraInvocationHandler));
1271        let req = single_call("Sub/set", json!({}), "c0");
1272        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
1273
1274        assert_eq!(
1275            resp.method_responses.len(),
1276            2,
1277            "primary + 1 extra = 2 total invocations"
1278        );
1279        // First: the primary Sub/set response.
1280        assert_eq!(resp.method_responses[0].0, "Sub/set");
1281        assert_eq!(resp.method_responses[0].2, "c0");
1282        assert_eq!(resp.method_responses[0].1["type"], "primary");
1283        // Second: the appended extra invocation.
1284        assert_eq!(resp.method_responses[1].0, "Extra/call");
1285        assert_eq!(resp.method_responses[1].2, "x0");
1286        assert_eq!(resp.method_responses[1].1["type"], "extra");
1287    }
1288
1289    /// Oracle: ClosureHandler forwards CallerCtx to the closure.
1290    /// The closure receives the exact same value that was passed to dispatch().
1291    #[tokio::test]
1292    async fn closure_handler_forwards_caller() {
1293        #[derive(Clone)]
1294        struct Ctx(String);
1295
1296        struct DummyBackend;
1297
1298        // Use a shared capture to record what ctx the closure received.
1299        let received: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1300        let received_clone = Arc::clone(&received);
1301
1302        let handler: Arc<ClosureHandler<DummyBackend, Ctx>> = Arc::new(ClosureHandler::new(
1303            Arc::new(DummyBackend),
1304            move |_b: Arc<DummyBackend>, _call_id: String, _args: Value, ctx: Ctx| {
1305                let cap = Arc::clone(&received_clone);
1306                Box::pin(async move {
1307                    *cap.lock().unwrap() = Some(ctx.0.clone());
1308                    Ok((serde_json::json!({}), vec![]))
1309                })
1310            },
1311        ));
1312
1313        let ctx = Ctx("alice".to_owned());
1314        handler
1315            .call("Test/get".into(), "c1".into(), serde_json::json!({}), ctx)
1316            .await
1317            .expect("handler must succeed");
1318
1319        assert_eq!(
1320            received.lock().unwrap().as_deref(),
1321            Some("alice"),
1322            "CallerCtx must be forwarded to the closure"
1323        );
1324    }
1325
1326    /// Oracle: ClosureHandler implements JmapHandler<C> and can be
1327    /// registered with Dispatcher<C>.
1328    #[test]
1329    fn closure_handler_is_jmap_handler() {
1330        // Compile-time check: ClosureHandler<B, C> must satisfy JmapHandler<C>.
1331        fn assert_handler<C: Clone + Send + 'static, H: JmapHandler<C>>(_: &H) {}
1332
1333        struct DummyBackend;
1334        #[derive(Clone)]
1335        struct Ctx;
1336
1337        let h = ClosureHandler::new(Arc::new(DummyBackend), |_b, _ci, _a, _ctx| {
1338            Box::pin(async { Ok((serde_json::json!({}), vec![])) })
1339        });
1340        assert_handler::<Ctx, _>(&h);
1341    }
1342}