Skip to main content

xmtp_mls_common/app_data/
custom.rs

1//! Host-defined ("custom") component registration.
2//!
3//! Custom components live in the app range (`0xC000-0xFEFF`,
4//! per [`ComponentId::is_app_range`]) and are registered by host
5//! apps at process startup. Unlike well-known components — which
6//! have static `Component` impls with `const ID: ComponentId` — a
7//! [`RuntimeComponent`] carries its id as a runtime value so a host
8//! can register multiple distinct components from one impl type
9//! parameterized by id (or build them dynamically).
10//!
11//! ## Lifecycle
12//!
13//! 1. **Process startup** (host): the host calls
14//!    [`register_global_runtime_component`] for each component it
15//!    wants to support. The registry stores a `&'static dyn
16//!    ErasedComponent`-shaped handle keyed by `ComponentId`.
17//! 2. **Dispatch lookup**: callers of
18//!    [`super::registry_table::lookup_component`] check the static
19//!    `WELL_KNOWN` table first; if no entry exists there and the id
20//!    is in the app range, the runtime registry is consulted.
21//! 3. **Per-group registration**: registering a custom component in
22//!    a *group's* `COMPONENT_REGISTRY` happens via
23//!    `IntentKind::UpdatePermission` (post-bootstrap), separately
24//!    from the host-process registration here. Both must happen
25//!    before a group can carry a custom component's value: the host
26//!    needs a `RuntimeComponent` impl to decode the bytes, and the
27//!    group needs a registry entry so writes pass
28//!    `validate_component_write`.
29//!
30//! ## Why a global rather than per-Client
31//!
32//! Threading a per-`Client` registry through every dispatch site
33//! (`apply_app_data_update_payload`, `expand_app_data_update_to_changes`,
34//! `validate_component_write`, the facade) would touch ~dozens of
35//! call shapes. A process-global registry keeps the dispatch
36//! signature stable and reflects the operational reality: hosts
37//! register their custom-component shapes once, at process startup,
38//! before any group is created. There is no use case for "different
39//! Clients in the same process know different custom components."
40
41use std::{
42    collections::HashMap,
43    sync::{Arc, OnceLock},
44};
45
46use parking_lot::RwLock;
47
48use openmls::messages::proposals::AppDataUpdateOperation;
49use xmtp_common::{MaybeSend, MaybeSync};
50use xmtp_proto::xmtp::mls::message_contents::ComponentType;
51
52use super::{
53    component_id::ComponentId,
54    component_registry::ComponentRegistry,
55    typed::{
56        ComponentInvariantError, ComponentTypedError, ErasedComponent, ExpandedComponentChange,
57    },
58    validation::ComponentChange,
59};
60
61/// A host-defined component whose `ComponentId` is known at runtime.
62///
63/// Unlike [`super::typed::Component`], `RuntimeComponent` takes
64/// `&self` and reports its id via [`Self::id`] so a single impl can
65/// service multiple distinct ids (or build them dynamically). The
66/// trait is otherwise structurally identical to `Component` minus
67/// the typed `Value` / `Mutation` associated types — runtime
68/// components handle bytes only, since the host owns whatever
69/// further decoding it does on top.
70///
71/// The bounds are [`MaybeSend`] + [`MaybeSync`] rather than `Send +
72/// Sync` so that WASM hosts can register impls that hold non-`Send`
73/// state (e.g. JS-bound objects). On native targets these expand to
74/// `Send + Sync` and the trait behaves identically to before; on
75/// `wasm32` they expand to vacuous bounds. The dispatch path's static
76/// storage requirements are bridged in [`RuntimeAdapter`] below — see
77/// the SAFETY note there.
78pub trait RuntimeComponent: MaybeSend + MaybeSync + 'static {
79    /// The component this instance handles.
80    fn id(&self) -> ComponentId;
81
82    /// Logical wire type, written into the registry entry's
83    /// `ComponentMetadata.component_type` slot. The host typically
84    /// picks one of the existing [`ComponentType`] variants
85    /// (`Bytes`, `String`, `TlsSetInboxId`, etc.) — runtime
86    /// components are not currently expected to introduce new
87    /// `ComponentType`s.
88    fn component_type(&self) -> ComponentType;
89
90    /// Apply an `AppDataUpdateOperation::Update` payload against the
91    /// prior bytes (if any) and produce the new full-state bytes.
92    fn apply_update_payload(
93        &self,
94        payload: &[u8],
95        prior: Option<&[u8]>,
96    ) -> Result<Vec<u8>, ComponentTypedError>;
97
98    /// Expand an `AppDataUpdate` proposal into per-element changes
99    /// for the validator's policy loop.
100    fn expand_to_changes(
101        &self,
102        op: &AppDataUpdateOperation,
103        prior: Option<&[u8]>,
104    ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError>;
105
106    /// Optional component-local invariant check. Default no-op.
107    fn validate_invariant(
108        &self,
109        _change: &ComponentChange<'_>,
110        _registry: &ComponentRegistry,
111    ) -> Result<(), ComponentInvariantError> {
112        Ok(())
113    }
114}
115
116/// Adapter so a [`RuntimeComponent`] can be used wherever an
117/// [`ErasedComponent`] is expected. The dispatch table consults
118/// runtime components via this adapter.
119struct RuntimeAdapter(Arc<dyn RuntimeComponent>);
120
121// SAFETY: wasm32 is single-threaded; the runtime registry's
122// `Arc<dyn RuntimeComponent>` is only ever accessed from the same
123// thread that registered it. On native, `RuntimeComponent` carries
124// `Send + Sync` (via `MaybeSend + MaybeSync`) so this impl is
125// unnecessary — only WASM needs the manual bridge because the trait
126// bounds are vacuous there but `ErasedComponent` (used by the static
127// dispatch table) still requires `Send + Sync`.
128#[cfg(target_arch = "wasm32")]
129unsafe impl Send for RuntimeAdapter {}
130#[cfg(target_arch = "wasm32")]
131unsafe impl Sync for RuntimeAdapter {}
132
133impl ErasedComponent for RuntimeAdapter {
134    fn id(&self) -> ComponentId {
135        self.0.id()
136    }
137
138    fn component_type(&self) -> ComponentType {
139        self.0.component_type()
140    }
141
142    fn apply_update_payload(
143        &self,
144        payload: &[u8],
145        prior: Option<&[u8]>,
146    ) -> Result<Vec<u8>, ComponentTypedError> {
147        self.0.apply_update_payload(payload, prior)
148    }
149
150    fn expand_to_changes(
151        &self,
152        op: &AppDataUpdateOperation,
153        prior: Option<&[u8]>,
154    ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
155        self.0.expand_to_changes(op, prior)
156    }
157
158    fn validate_invariant(
159        &self,
160        change: &ComponentChange<'_>,
161        registry: &ComponentRegistry,
162    ) -> Result<(), ComponentInvariantError> {
163        self.0.validate_invariant(change, registry)
164    }
165}
166
167/// Errors surfaced by [`register_global_runtime_component`].
168#[derive(Debug, thiserror::Error)]
169pub enum RuntimeRegistrationError {
170    /// The id is outside the app range (`0xC000-0xFEFF`).
171    /// XMTP-range ids are reserved for static well-known impls.
172    #[error("component id {0} is not in the app-defined range (0xC000-0xFEFF)")]
173    OutOfRange(ComponentId),
174
175    /// The id is already registered. Re-registration is rejected
176    /// unconditionally — even with the same impl — because
177    /// `Arc<dyn>` and `&'static dyn` have different fat-pointer
178    /// layouts, so we can't cheaply detect "same impl" without
179    /// extra bookkeeping. Hosts should `Once`-gate their startup
180    /// registration paths.
181    #[error("component id {0} is already registered")]
182    AlreadyRegistered(ComponentId),
183}
184
185/// Process-global runtime component registry.
186///
187/// Stores `Arc<dyn RuntimeComponent>` keyed by `ComponentId`. The
188/// adapter wrapping each entry into `&'static dyn ErasedComponent`
189/// is also cached so the dispatch path can hand back a stable
190/// `&'static` reference. Updates happen at process startup; reads
191/// happen on every commit's validation path.
192struct RuntimeRegistry {
193    inner: RwLock<HashMap<ComponentId, &'static dyn ErasedComponent>>,
194}
195
196impl RuntimeRegistry {
197    fn new() -> Self {
198        Self {
199            inner: RwLock::new(HashMap::new()),
200        }
201    }
202
203    fn register(
204        &self,
205        component: Arc<dyn RuntimeComponent>,
206    ) -> Result<(), RuntimeRegistrationError> {
207        let id = component.id();
208        if !id.is_app_range() {
209            return Err(RuntimeRegistrationError::OutOfRange(id));
210        }
211        let mut guard = self.inner.write();
212        if let Some(_existing) = guard.get(&id) {
213            // Bypass the duplicate check if pointer-equal: re-
214            // registering the same impl is harmless and lets hosts
215            // call `register_global_runtime_component` from
216            // idempotent setup paths.
217            //
218            // We can't compare the underlying `Arc<dyn ...>`
219            // pointers directly because `&'static dyn` and
220            // `Arc<dyn>` have different fat-pointer layouts. For
221            // now treat any second registration as an error;
222            // hosts can `Once`-gate their setup.
223            return Err(RuntimeRegistrationError::AlreadyRegistered(id));
224        }
225        // Leak the adapter to get a `&'static` — runtime components
226        // live for the process lifetime by design (registered at
227        // startup, never unregistered). `Box::leak` of an `Arc`
228        // adapter is the simplest way to mint a static reference
229        // that the dispatch table can hand back.
230        let adapter: Box<dyn ErasedComponent> = Box::new(RuntimeAdapter(component));
231        let leaked: &'static dyn ErasedComponent = Box::leak(adapter);
232        guard.insert(id, leaked);
233        Ok(())
234    }
235
236    fn lookup(&self, id: ComponentId) -> Option<&'static dyn ErasedComponent> {
237        self.inner.read().get(&id).copied()
238    }
239}
240
241fn registry() -> &'static RuntimeRegistry {
242    static REGISTRY: OnceLock<RuntimeRegistry> = OnceLock::new();
243    REGISTRY.get_or_init(RuntimeRegistry::new)
244}
245
246/// Register a host-defined runtime component.
247///
248/// Call once per component at process startup, before any
249/// `Client` operates on a group that may carry this component.
250/// Returns `Err(OutOfRange)` if the id is outside `0xC000-0xFEFF`,
251/// `Err(AlreadyRegistered)` if a different impl is already
252/// registered for the same id.
253///
254/// The component lives for the process lifetime — there is no
255/// `unregister`. Hosts that need to swap impls should restart.
256pub fn register_global_runtime_component(
257    component: Arc<dyn RuntimeComponent>,
258) -> Result<(), RuntimeRegistrationError> {
259    registry().register(component)
260}
261
262/// Look up a runtime-registered component by id.
263///
264/// Returns `None` if the id is not in the app range (those go
265/// through the static `WELL_KNOWN` dispatch table) or if no host
266/// has registered a `RuntimeComponent` for the id yet.
267pub(crate) fn lookup_runtime_component(id: ComponentId) -> Option<&'static dyn ErasedComponent> {
268    if !id.is_app_range() {
269        return None;
270    }
271    registry().lookup(id)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::app_data::component_registry::ComponentOp;
278
279    /// A test-only RuntimeComponent that just passes Bytes through.
280    /// Each instance carries its own id so different test cases
281    /// don't collide on the global registry.
282    struct PassthroughBytes(ComponentId);
283
284    impl RuntimeComponent for PassthroughBytes {
285        fn id(&self) -> ComponentId {
286            self.0
287        }
288
289        fn component_type(&self) -> ComponentType {
290            ComponentType::Bytes
291        }
292
293        fn apply_update_payload(
294            &self,
295            payload: &[u8],
296            _prior: Option<&[u8]>,
297        ) -> Result<Vec<u8>, ComponentTypedError> {
298            Ok(payload.to_vec())
299        }
300
301        fn expand_to_changes(
302            &self,
303            op: &AppDataUpdateOperation,
304            _prior: Option<&[u8]>,
305        ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
306            match op {
307                AppDataUpdateOperation::Update(p) => Ok(vec![ExpandedComponentChange {
308                    op: ComponentOp::Update,
309                    value: Some(p.as_slice().to_vec()),
310                }]),
311                AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
312                    op: ComponentOp::Delete,
313                    value: None,
314                }]),
315            }
316        }
317    }
318
319    #[xmtp_common::test(unwrap_try = true)]
320    fn rejects_out_of_range_ids() {
321        let well_known_id = ComponentId::GROUP_NAME; // XMTP range
322        let err = register_global_runtime_component(Arc::new(PassthroughBytes(well_known_id)))
323            .unwrap_err();
324        assert!(matches!(
325            err,
326            RuntimeRegistrationError::OutOfRange(id) if id == well_known_id
327        ));
328    }
329
330    #[xmtp_common::test(unwrap_try = true)]
331    fn registers_and_dispatches_through_lookup() {
332        // Pick a unique id per test run to avoid cross-test
333        // collisions on the process-global registry. 0xC100 is
334        // arbitrary but stays in the app range.
335        let id = ComponentId::new(0xC100);
336
337        // First registration succeeds.
338        register_global_runtime_component(Arc::new(PassthroughBytes(id))).unwrap();
339
340        // Lookup returns an ErasedComponent that handles the id.
341        let entry = lookup_runtime_component(id).expect("registered component should look up");
342        assert_eq!(entry.id(), id);
343        assert_eq!(entry.component_type(), ComponentType::Bytes);
344
345        // Apply path works.
346        let new = entry.apply_update_payload(b"hello", None).unwrap();
347        assert_eq!(new, b"hello");
348    }
349
350    #[xmtp_common::test(unwrap_try = true)]
351    fn duplicate_registration_rejected() {
352        // Use a different id than `registers_and_dispatches_through_lookup`
353        // so test ordering doesn't matter.
354        let id = ComponentId::new(0xC101);
355        register_global_runtime_component(Arc::new(PassthroughBytes(id))).unwrap();
356
357        let err = register_global_runtime_component(Arc::new(PassthroughBytes(id))).unwrap_err();
358        assert!(matches!(
359            err,
360            RuntimeRegistrationError::AlreadyRegistered(seen) if seen == id
361        ));
362    }
363
364    #[xmtp_common::test(unwrap_try = true)]
365    fn lookup_returns_none_for_unregistered_id() {
366        // 0xC1FF is reserved for "no test ever uses this" — so
367        // this lookup deterministically returns None even when
368        // tests run in arbitrary order.
369        let unregistered = ComponentId::new(0xC1FF);
370        assert!(lookup_runtime_component(unregistered).is_none());
371
372        // XMTP-range ids always return None from the runtime path
373        // (they're handled by the static dispatch table instead).
374        assert!(lookup_runtime_component(ComponentId::GROUP_NAME).is_none());
375    }
376}